dokploy/packages/server/src/utils/process/execAsync.ts

572 lines
13 KiB
TypeScript

import { type ChildProcess, exec, execFile, spawn } from "node:child_process";
import util from "node:util";
import { findServerById } from "@dokploy/server/services/server";
import { Client, type ClientChannel } from "ssh2";
import { ExecError } from "./ExecError";
import { withRemoteDeadline } from "./remote-deadline";
export class WriteFileRemoteError extends Error {
constructor(
message: string,
public readonly context: {
remotePath: string;
serverId: string;
originalError: Error;
},
) {
super(message);
this.name = "WriteFileRemoteError";
}
}
// Re-export ExecError for easier imports
export { ExecError } from "./ExecError";
const execAsyncBase = util.promisify(exec);
export interface ExecAsyncOptions {
cwd?: string;
env?: NodeJS.ProcessEnv;
shell?: string;
timeout?: number;
}
export interface ExecAsyncRemoteOptions {
timeout?: number;
}
const killProcessGroup = (child: ChildProcess) => {
if (child.pid == null) return;
if (process.platform === "win32") {
child.kill("SIGKILL");
return;
}
try {
process.kill(-child.pid, "SIGKILL");
} catch {
try {
child.kill("SIGKILL");
} catch {
// already exited
}
}
};
const execAsyncTimed = (
command: string,
options: ExecAsyncOptions & { timeout: number },
): Promise<{ stdout: string; stderr: string }> => {
const { timeout, cwd, env, shell } = options;
const isWin = process.platform === "win32";
const shellPath =
shell ?? (isWin ? process.env.ComSpec || "cmd.exe" : "/bin/sh");
const args = isWin ? ["/d", "/s", "/c", command] : ["-c", command];
return new Promise((resolve, reject) => {
let stdout = "";
let stderr = "";
let settled = false;
const child = spawn(shellPath, args, {
cwd,
env,
detached: !isWin,
stdio: ["ignore", "pipe", "pipe"],
});
const timer = setTimeout(() => {
killProcessGroup(child);
finish(
new ExecError(`Command execution timed out after ${timeout}ms`, {
command,
stdout,
stderr,
}),
);
}, timeout);
const finish = (error?: ExecError) => {
if (settled) return;
settled = true;
clearTimeout(timer);
if (error) reject(error);
else resolve({ stdout, stderr });
};
child.stdout?.on("data", (data) => {
stdout += data.toString();
});
child.stderr?.on("data", (data) => {
stderr += data.toString();
});
child.on("error", (error) => {
finish(
new ExecError(`Command execution failed: ${error.message}`, {
command,
stdout,
stderr,
originalError: error,
}),
);
});
child.on("close", (code) => {
if (code === 0) {
finish();
return;
}
finish(
new ExecError(`Command execution failed: Command failed: ${command}`, {
command,
stdout,
stderr,
exitCode: code ?? undefined,
}),
);
});
});
};
export const execAsync = async (
command: string,
options?: ExecAsyncOptions,
): Promise<{ stdout: string; stderr: string }> => {
const timeout = options?.timeout;
if (timeout != null && Number.isFinite(timeout) && timeout > 0) {
return execAsyncTimed(command, { ...options, timeout });
}
const { timeout: _timeout, ...execOptions } = options ?? {};
try {
const result = await execAsyncBase(command, execOptions);
return {
stdout: result.stdout.toString(),
stderr: result.stderr.toString(),
};
} catch (error) {
if (error instanceof Error) {
// @ts-expect-error - exec error has these properties
const exitCode = error.code;
// @ts-expect-error
const stdout = error.stdout?.toString() || "";
// @ts-expect-error
const stderr = error.stderr?.toString() || "";
throw new ExecError(`Command execution failed: ${error.message}`, {
command,
stdout,
stderr,
exitCode,
originalError: error,
});
}
throw error;
}
};
interface ExecOptions {
cwd?: string;
env?: NodeJS.ProcessEnv;
}
export const execAsyncStream = (
command: string,
onData?: (data: string) => void,
options: ExecOptions = {},
): Promise<{ stdout: string; stderr: string }> => {
return new Promise((resolve, reject) => {
let stdoutComplete = "";
let stderrComplete = "";
const childProcess = exec(command, options, (error) => {
if (error) {
reject(
new ExecError(`Command execution failed: ${error.message}`, {
command,
stdout: stdoutComplete,
stderr: stderrComplete,
exitCode: error.code,
originalError: error,
}),
);
return;
}
resolve({ stdout: stdoutComplete, stderr: stderrComplete });
});
childProcess.stdout?.on("data", (data: Buffer | string) => {
const stringData = data.toString();
stdoutComplete += stringData;
if (onData) {
onData(stringData);
}
});
childProcess.stderr?.on("data", (data: Buffer | string) => {
const stringData = data.toString();
stderrComplete += stringData;
if (onData) {
onData(stringData);
}
});
childProcess.on("error", (error) => {
console.log(error);
reject(
new ExecError(`Command execution error: ${error.message}`, {
command,
stdout: stdoutComplete,
stderr: stderrComplete,
originalError: error,
}),
);
});
});
};
export const execFileAsync = async (
command: string,
args: string[],
options: { input?: string } = {},
): Promise<{ stdout: string; stderr: string }> => {
const child = execFile(command, args);
if (options.input && child.stdin) {
child.stdin.write(options.input);
child.stdin.end();
}
return new Promise((resolve, reject) => {
let stdout = "";
let stderr = "";
child.stdout?.on("data", (data) => {
stdout += data.toString();
});
child.stderr?.on("data", (data) => {
stderr += data.toString();
});
child.on("close", (code) => {
if (code === 0) {
resolve({ stdout, stderr });
} else {
reject(
new Error(`Command failed with code ${code}. Stderr: ${stderr}`),
);
}
});
child.on("error", reject);
});
};
export const execAsyncRemote = async (
serverId: string | null,
command: string,
onData?: (data: string) => void,
options?: ExecAsyncRemoteOptions,
): Promise<{ stdout: string; stderr: string }> => {
if (!serverId) return { stdout: "", stderr: "" };
const server = await findServerById(serverId);
if (!server.sshKeyId) throw new Error("No SSH key available for this server");
let stdout = "";
let stderr = "";
const requestedTimeout = options?.timeout;
const timeoutMs =
requestedTimeout != null &&
Number.isFinite(requestedTimeout) &&
requestedTimeout > 0
? requestedTimeout
: undefined;
const started = Date.now();
return new Promise((resolve, reject) => {
const conn = new Client();
let stream: ClientChannel | undefined;
let settled = false;
let timer: ReturnType<typeof setTimeout> | undefined;
const closeStream = (channel?: ClientChannel) => {
if (timeoutMs !== undefined) {
try {
channel?.signal("KILL");
} catch {
/* remote watchdog owns cleanup */
}
}
try {
channel?.close();
} catch {
/* already closed */
}
try {
channel?.destroy();
} catch {
/* already destroyed */
}
};
const settle = (
error?: ExecError,
value?: { stdout: string; stderr: string },
) => {
if (settled) return;
settled = true;
if (timer !== undefined) {
clearTimeout(timer);
timer = undefined;
}
if (error) {
closeStream(stream);
try {
conn.end();
} catch {
// already ended
}
try {
conn.destroy();
} catch {
// already destroyed
}
reject(error);
return;
}
try {
conn.end();
} catch {
// already ended
}
resolve(value ?? { stdout, stderr });
};
if (timeoutMs != null && timeoutMs > 0) {
timer = setTimeout(() => {
settle(
new ExecError(`Command execution timed out after ${timeoutMs}ms`, {
command,
stdout,
stderr,
serverId,
}),
);
}, timeoutMs);
}
try {
conn
.once("ready", () => {
if (settled) return;
const remoteCommand =
timeoutMs === undefined
? command
: withRemoteDeadline(
command,
Math.max(1, timeoutMs - (Date.now() - started)),
);
try {
conn.exec(remoteCommand, (err, commandStream) => {
if (settled) {
closeStream(commandStream);
return;
}
if (err) {
onData?.(err.message);
settle(
new ExecError(
`Remote command execution failed: ${err.message}`,
{
command,
serverId,
originalError: err,
},
),
);
return;
}
stream = commandStream;
commandStream
.on("error", (error: Error) => {
if (settled) return;
settle(
new ExecError(
`Remote command stream failed: ${error.message}`,
{
command,
stdout,
stderr,
serverId,
originalError: error,
},
),
);
})
.on("close", (code: number, _signal: string) => {
if (code === 0) {
settle(undefined, { stdout, stderr });
} else {
settle(
new ExecError(
`Remote command failed with exit code ${code}`,
{
command,
stdout,
stderr,
exitCode: code,
serverId,
},
),
);
}
})
.on("data", (data: string) => {
if (settled) return;
stdout += data.toString();
onData?.(data.toString());
})
.stderr.on("data", (data) => {
if (settled) return;
stderr += data.toString();
onData?.(data.toString());
});
});
} catch (error) {
settle(
new ExecError("Remote command execution failed", {
command,
stdout,
stderr,
serverId,
originalError: error instanceof Error ? error : undefined,
}),
);
}
})
.on("error", (err) => {
if (settled) return;
if (err.level === "client-authentication") {
const technicalDetail = `Error: ${err.message} ${err.level}`;
const friendlyMessage = [
"",
"❌ Couldn't connect to your server — the SSH key was not accepted.",
"",
"This usually means the key doesn't match what's on the server, or the key format is invalid.",
"",
`Technical details: ${technicalDetail}`,
"",
"💡 Hints:",
" • Check that the SSH key you added in Dokploy is the same one installed on the server (e.g. in ~/.ssh/authorized_keys).",
" • Try generating a new SSH key in Dokploy and add only the public key to the server, then try again.",
" • Make sure to follow the instructions on the Setup Server Button on the SSH Keys tab and then click on deployments tab and check the logs for more details.",
].join("\n");
onData?.(friendlyMessage);
settle(
new ExecError(
`Authentication failed: Invalid SSH private key. ${friendlyMessage}`,
{
command,
serverId,
originalError: err,
},
),
);
} else {
const errorMsg = `SSH connection error: ${err.message}`;
onData?.(errorMsg);
settle(
new ExecError(errorMsg, {
stdout,
stderr,
command,
serverId,
originalError: err,
}),
);
}
})
.connect({
host: server.ipAddress,
port: server.port,
username: server.username,
privateKey: server.sshKey?.privateKey,
timeout: timeoutMs != null && timeoutMs > 0 ? timeoutMs : 99999,
...(timeoutMs != null && timeoutMs > 0
? { readyTimeout: timeoutMs }
: {}),
});
} catch (error) {
settle(
new ExecError("SSH connection failed", {
command,
stdout,
stderr,
serverId,
originalError: error instanceof Error ? error : undefined,
}),
);
}
});
};
export const writeFileRemote = async (
serverId: string,
remotePath: string,
content: string,
): Promise<void> => {
const server = await findServerById(serverId);
if (!server.sshKeyId) throw new Error("No SSH key available for this server");
return new Promise((resolve, reject) => {
const conn = new Client();
conn
.once("ready", () => {
conn.sftp((err, sftp) => {
if (err) {
conn.end();
reject(
new WriteFileRemoteError(`SFTP session failed: ${err.message}`, {
remotePath,
serverId,
originalError: err,
}),
);
return;
}
sftp.writeFile(remotePath, content, (writeErr) => {
conn.end();
if (writeErr) {
reject(
new WriteFileRemoteError(
`Failed to write remote file ${remotePath}: ${writeErr.message}`,
{ remotePath, serverId, originalError: writeErr },
),
);
return;
}
resolve();
});
});
})
.on("error", (err) => {
conn.end();
reject(
new WriteFileRemoteError(`SSH connection error: ${err.message}`, {
remotePath,
serverId,
originalError: err,
}),
);
})
.connect({
host: server.ipAddress,
port: server.port,
username: server.username,
privateKey: server.sshKey?.privateKey,
timeout: 99999,
});
});
};
export const sleep = (ms: number) => {
return new Promise((resolve) => setTimeout(resolve, ms));
};