fix: await SSH tunnel readiness
This commit is contained in:
@@ -160,8 +160,9 @@ export interface ConcreteDiagnosticAdapterDependencies {
|
|||||||
directProtocol?: DirectProtocolFactory;
|
directProtocol?: DirectProtocolFactory;
|
||||||
sshProcess?: SshProcessFactory;
|
sshProcess?: SshProcessFactory;
|
||||||
databaseClient?: DatabaseDiagnosticClientFactory;
|
databaseClient?: DatabaseDiagnosticClientFactory;
|
||||||
sshSpawn?: (args: readonly string[]) => { kill(signal?: NodeJS.Signals): boolean };
|
sshSpawn?: (args: readonly string[]) => { kill(signal?: NodeJS.Signals): boolean; once?(event: "error" | "exit", listener: (...args: any[]) => void): unknown };
|
||||||
reserveLoopbackPort?: () => Promise<number>;
|
reserveLoopbackPort?: () => Promise<number>;
|
||||||
|
waitForSshReady?: (tunnel: LoopbackTunnel, signal: AbortSignal) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -231,6 +232,18 @@ async function reserveLoopbackPort(): Promise<number> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function waitForLoopbackTunnel(tunnel: LoopbackTunnel, signal: AbortSignal): Promise<void> {
|
||||||
|
while (!signal.aborted) {
|
||||||
|
try {
|
||||||
|
await connectTcp(tunnel.host, tunnel.port, signal);
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
await new Promise<void>((resolve) => setTimeout(resolve, 25));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error("SSH tunnel readiness failed");
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Concrete production adapters deliberately retain only probe metadata. Protocol failures and
|
* Concrete production adapters deliberately retain only probe metadata. Protocol failures and
|
||||||
* response bodies are discarded at this boundary; callers receive fixed diagnostics instead.
|
* response bodies are discarded at this boundary; callers receive fixed diagnostics instead.
|
||||||
@@ -240,6 +253,7 @@ export function createConcreteDiagnosticAdapters(
|
|||||||
): DiagnosticAdapters {
|
): DiagnosticAdapters {
|
||||||
const spawnSsh = dependencies.sshSpawn ?? ((args: readonly string[]) => spawn("ssh", [...args], { stdio: "ignore" }));
|
const spawnSsh = dependencies.sshSpawn ?? ((args: readonly string[]) => spawn("ssh", [...args], { stdio: "ignore" }));
|
||||||
const reserveSshPort = dependencies.reserveLoopbackPort ?? reserveLoopbackPort;
|
const reserveSshPort = dependencies.reserveLoopbackPort ?? reserveLoopbackPort;
|
||||||
|
const waitForSshReady = dependencies.waitForSshReady ?? waitForLoopbackTunnel;
|
||||||
const sshProcess = dependencies.sshProcess ?? {
|
const sshProcess = dependencies.sshProcess ?? {
|
||||||
async start(request: SshTunnelRequest, args: readonly string[]) {
|
async start(request: SshTunnelRequest, args: readonly string[]) {
|
||||||
const port = await reserveSshPort();
|
const port = await reserveSshPort();
|
||||||
@@ -248,9 +262,29 @@ export function createConcreteDiagnosticAdapters(
|
|||||||
const child = spawnSsh(resolvedArgs);
|
const child = spawnSsh(resolvedArgs);
|
||||||
const abort = () => { child.kill("SIGTERM"); };
|
const abort = () => { child.kill("SIGTERM"); };
|
||||||
request.signal.addEventListener("abort", abort, { once: true });
|
request.signal.addEventListener("abort", abort, { once: true });
|
||||||
|
const tunnel = { host: "127.0.0.1" as const, port };
|
||||||
|
try {
|
||||||
|
await withTimeout(request.timeoutMs, async (signal) => {
|
||||||
|
await Promise.race([
|
||||||
|
waitForSshReady(tunnel, signal),
|
||||||
|
new Promise<never>((_resolve, reject) => {
|
||||||
|
child.once?.("error", () => reject(new Error("SSH tunnel readiness failed")));
|
||||||
|
child.once?.("exit", () => reject(new Error("SSH tunnel readiness failed")));
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
request.signal.removeEventListener("abort", abort);
|
||||||
|
child.kill("SIGTERM");
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
tunnel: { host: "127.0.0.1" as const, port },
|
tunnel,
|
||||||
async close() { request.signal.removeEventListener("abort", abort); child.kill("SIGTERM"); },
|
async close() {
|
||||||
|
request.signal.removeEventListener("abort", abort);
|
||||||
|
child.kill("SIGTERM");
|
||||||
|
if (child.once) await withTimeout(request.timeoutMs, () => new Promise<void>((resolve) => child.once?.("exit", resolve))).catch(() => undefined);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -332,7 +366,7 @@ export function createConcreteDiagnosticAdapters(
|
|||||||
throw new Error("SSH probe failed");
|
throw new Error("SSH probe failed");
|
||||||
}
|
}
|
||||||
const args = [
|
const args = [
|
||||||
"-N", "-o", "BatchMode=yes", "-o", "StrictHostKeyChecking=yes",
|
"-N", "-o", "BatchMode=yes", "-o", "ExitOnForwardFailure=yes", "-o", "StrictHostKeyChecking=yes",
|
||||||
"-o", `UserKnownHostsFile=${request.knownHostsFile}`, "-i", request.privateKeyFile,
|
"-o", `UserKnownHostsFile=${request.knownHostsFile}`, "-i", request.privateKeyFile,
|
||||||
"-p", String(request.sshPort), "-L", `127.0.0.1:0:${request.targetHost}:${request.targetPort}`,
|
"-p", String(request.sshPort), "-L", `127.0.0.1:0:${request.targetHost}:${request.targetPort}`,
|
||||||
`${request.sshUser}@${request.sshHost}`,
|
`${request.sshUser}@${request.sshHost}`,
|
||||||
|
|||||||
@@ -333,7 +333,7 @@ test("provides a default bounded SSH factory through injected spawn and loopback
|
|||||||
const kill = vi.fn(() => true);
|
const kill = vi.fn(() => true);
|
||||||
const sshSpawn = vi.fn(() => ({ kill }));
|
const sshSpawn = vi.fn(() => ({ kill }));
|
||||||
try {
|
try {
|
||||||
const adapter = createConcreteDiagnosticAdapters({ sshSpawn, reserveLoopbackPort: async () => 45432 } as any);
|
const adapter = createConcreteDiagnosticAdapters({ sshSpawn, reserveLoopbackPort: async () => 45432, waitForSshReady: async () => undefined } as any);
|
||||||
await adapter.withSshTunnel({ sshHost: "bastion.example.test", sshPort: 22, sshUser: "tunnel", privateKeyFile, knownHostsFile: "/run/secrets/known-hosts", targetHost: "dwh.internal", targetPort: 5432, localHost: "127.0.0.1", localPort: 0, timeoutMs: 5000, signal: new AbortController().signal }, async () => undefined);
|
await adapter.withSshTunnel({ sshHost: "bastion.example.test", sshPort: 22, sshUser: "tunnel", privateKeyFile, knownHostsFile: "/run/secrets/known-hosts", targetHost: "dwh.internal", targetPort: 5432, localHost: "127.0.0.1", localPort: 0, timeoutMs: 5000, signal: new AbortController().signal }, async () => undefined);
|
||||||
expect(sshSpawn).toHaveBeenCalledWith(expect.arrayContaining([
|
expect(sshSpawn).toHaveBeenCalledWith(expect.arrayContaining([
|
||||||
"StrictHostKeyChecking=yes", "UserKnownHostsFile=/run/secrets/known-hosts", "-L", "127.0.0.1:45432:dwh.internal:5432",
|
"StrictHostKeyChecking=yes", "UserKnownHostsFile=/run/secrets/known-hosts", "-L", "127.0.0.1:45432:dwh.internal:5432",
|
||||||
@@ -344,6 +344,28 @@ test("provides a default bounded SSH factory through injected spawn and loopback
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("waits for SSH readiness before probing and includes ExitOnForwardFailure", async () => {
|
||||||
|
const directory = await mkdtemp(join(tmpdir(), "thothii-diagnostic-"));
|
||||||
|
const privateKeyFile = join(directory, "ssh-key");
|
||||||
|
await writeFile(privateKeyFile, "test-key\n", { mode: 0o600 });
|
||||||
|
let releaseReady: (() => void) | undefined;
|
||||||
|
const ready = new Promise<void>((resolve) => { releaseReady = resolve; });
|
||||||
|
const probe = vi.fn(async () => undefined);
|
||||||
|
const sshSpawn = vi.fn(() => ({ kill: vi.fn(() => true) }));
|
||||||
|
try {
|
||||||
|
const adapter = createConcreteDiagnosticAdapters({ sshSpawn, reserveLoopbackPort: async () => 45432, waitForSshReady: async () => await ready } as any);
|
||||||
|
const running = adapter.withSshTunnel({ sshHost: "bastion.example.test", sshPort: 22, sshUser: "tunnel", privateKeyFile, knownHostsFile: "/run/secrets/known-hosts", targetHost: "dwh.internal", targetPort: 5432, localHost: "127.0.0.1", localPort: 0, timeoutMs: 5000, signal: new AbortController().signal }, probe);
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(probe).not.toHaveBeenCalled();
|
||||||
|
releaseReady?.();
|
||||||
|
await running;
|
||||||
|
expect(sshSpawn).toHaveBeenCalledWith(expect.arrayContaining(["ExitOnForwardFailure=yes"]));
|
||||||
|
expect(probe).toHaveBeenCalledOnce();
|
||||||
|
} finally {
|
||||||
|
await rm(directory, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test("requires a matching embedding model vector and removes its unique write probe", async () => {
|
test("requires a matching embedding model vector and removes its unique write probe", async () => {
|
||||||
const adapters = successfulAdapters();
|
const adapters = successfulAdapters();
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user