Files
pr-preview/backend/src/services/ssh.ts
T
space d86b35f231
Deploy / Build (push) Successful in 35s
Deploy / Build and Push Docker Image (push) Successful in 1m10s
feat(deploy, ec2, ssh): add timeout parameters for SSH and Git commands, improve AWS credentials logging
2026-07-26 15:14:16 +02:00

173 lines
6.0 KiB
TypeScript

import { Client } from "ssh2";
import { createLogger } from "../lib/logger";
const log = createLogger("SSH");
export interface SshSession {
exec(command: string, timeoutMs?: number): Promise<{ stdout: string; stderr: string; code: number }>;
// Run a long-lived command (e.g. `tail -f`) and receive its stdout/stderr as
// it arrives. Unlike exec(), this never buffers or times out — the channel
// stays open until the returned handle's close() is called (or the connection
// ends). Used to stream live app logs off a running instance.
execStream(command: string, onData: (chunk: string) => void): { close(): void };
close(): void;
aborted: boolean;
abort(): void;
}
export async function connectSsh(host: string, privateKey: string, maxWaitMs = 300_000): Promise<SshSession> {
const start = Date.now();
while (Date.now() - start < maxWaitMs) {
try {
const conn = await tryConnect(host, privateKey, 10000);
let aborted = false;
return {
get aborted() { return aborted; },
abort() {
aborted = true;
try { conn.end(); } catch {}
},
async exec(command: string, timeoutMs?: number) {
if (aborted) throw new Error("SSH session aborted");
return execOnConn(conn, command, timeoutMs);
},
execStream(command: string, onData: (chunk: string) => void) {
let stream: any = null;
let closed = false;
conn.exec(command, (err, s) => {
if (err) {
if (!closed) onData(`[app-logs] stream error: ${err.message}\n`);
return;
}
if (closed) { try { s.close(); } catch {} return; }
stream = s;
s.on("data", (d: Buffer) => onData(d.toString()));
s.stderr.on("data", (d: Buffer) => onData(d.toString()));
});
return {
close() {
closed = true;
try { stream?.close(); } catch {}
},
};
},
close() {
try { conn.end(); } catch {}
},
};
} catch (e: any) {
if (Date.now() - start > maxWaitMs) throw e;
log.debug({ host, error: e.message }, "SSH connect retry");
await sleep(5000);
}
}
throw new Error(`Could not SSH into ${host} within timeout`);
}
function tryConnect(host: string, privateKey: string, timeoutMs: number): Promise<Client> {
return new Promise((resolve, reject) => {
const conn = new Client();
const timer = setTimeout(() => {
conn.end();
reject(new Error(`SSH connection to ${host} timed out`));
}, timeoutMs);
conn.on("ready", () => {
clearTimeout(timer);
resolve(conn);
});
conn.on("error", (e) => {
clearTimeout(timer);
reject(e);
});
conn.connect({
host,
port: 22,
username: "ubuntu",
privateKey,
readyTimeout: timeoutMs,
keepaliveInterval: 10000,
keepaliveCountMax: 3,
algorithms: {
serverHostKey: ["ssh-rsa", "ecdsa-sha2-nistp256", "ecdsa-sha2-nistp384", "ecdsa-sha2-nistp521", "ssh-ed25519"],
},
});
});
}
const EXEC_TIMEOUT_MS = 30_000;
function execOnConn(conn: Client, command: string, timeoutMs = EXEC_TIMEOUT_MS): Promise<{ stdout: string; stderr: string; code: number }> {
return new Promise((resolve, reject) => {
let stream: any = null;
let settled = false;
const settle = (fn: () => void) => {
if (settled) return;
settled = true;
clearTimeout(timer);
fn();
};
const timer = setTimeout(() => {
settle(() => reject(new Error(`SSH exec timed out after ${Math.round(timeoutMs / 1000)}s: ${command.slice(0, 60)}`)));
try { stream?.close(); } catch {}
}, timeoutMs);
conn.exec(command, (err, s) => {
if (err) {
return settle(() => reject(err));
}
stream = s;
let stdout = "";
let stderr = "";
stream.on("data", (d: Buffer) => { stdout += d.toString(); });
stream.stderr.on("data", (d: Buffer) => { stderr += d.toString(); });
stream.on("close", (code: number) => {
settle(() => resolve({ stdout, stderr, code: code ?? 0 }));
});
});
});
}
// The `ubuntu` user accepts SSH almost immediately at boot — long before the
// cloud-init UserData bootstrap has finished disabling apt-daily and installing
// the small base dependency set PP needs before SSH-driven setup begins.
// Proceeding early can still cause apt-lock failures in the deploy steps. Block until the bootstrap writes its
// done marker; bail out fast (with the tail of its log) if it writes the failure
// marker instead. Uses short one-shot execs so a stale channel just retries.
export async function waitForBootstrap(session: SshSession, maxWaitMs = 600_000): Promise<void> {
const start = Date.now();
while (Date.now() - start < maxWaitMs) {
if (session.aborted) throw new Error("SSH session aborted");
try {
const res = await session.exec(
"if [ -f /var/lib/pp-bootstrap-failed ]; then echo failed; " +
"elif [ -f /var/lib/pp-bootstrap-done ]; then echo done; else echo waiting; fi"
);
const state = res.stdout.trim();
if (state === "done") return;
if (state === "failed") {
let tail = "";
try {
const logRes = await session.exec("tail -n 30 /var/log/pp-bootstrap.log 2>/dev/null || true");
tail = logRes.stdout.trim();
} catch {
// best effort — the failure marker alone is enough to abort
}
throw new Error(`EC2 bootstrap failed${tail ? `:\n${tail}` : ""}`);
}
} catch (e: any) {
// A bootstrap failure we detected above must propagate; only swallow
// transient SSH exec errors (timeout, channel reset) and retry.
if (e?.message?.startsWith("EC2 bootstrap failed")) throw e;
}
await sleep(10_000);
}
throw new Error("EC2 bootstrap did not complete within timeout");
}
function sleep(ms: number) {
return new Promise(r => setTimeout(r, ms));
}