fix: use frontend ssh bridge without backend-core
This commit is contained in:
+94
-2
@@ -1,4 +1,4 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { appendFileSync, mkdirSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
@@ -50,6 +50,19 @@ export interface SshCaptureResult {
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
export type SshCaptureBackend = "local-backend-core-broker" | "remote-frontend-websocket";
|
||||
|
||||
export interface SshCaptureBackendPlan {
|
||||
backend: SshCaptureBackend;
|
||||
remoteHost: string | null;
|
||||
reason: string;
|
||||
localBackendCore: {
|
||||
dockerExecutable: boolean;
|
||||
backendCoreContainer: boolean;
|
||||
error: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface SshFailureHint {
|
||||
code: "ssh-like-command-friction";
|
||||
providerId: string;
|
||||
@@ -2830,6 +2843,19 @@ async function runSshCaptureRemoteCommand(config: UniDeskConfig, invocation: Par
|
||||
});
|
||||
}
|
||||
|
||||
async function runRemoteSsh(config: UniDeskConfig, host: string, providerId: string, args: string[]): Promise<number> {
|
||||
const { runRemoteCli } = await import("./remote");
|
||||
return await runRemoteCli({
|
||||
host,
|
||||
user: "root",
|
||||
port: 22,
|
||||
projectRoot: repoRoot,
|
||||
identityFile: null,
|
||||
transport: "frontend",
|
||||
args: ["ssh", providerId, ...args],
|
||||
}, config);
|
||||
}
|
||||
|
||||
export async function runSshCommandCapture(config: UniDeskConfig, target: string, args: string[], input?: string): Promise<SshCaptureResult> {
|
||||
const invocation = parseSshInvocation(target, args);
|
||||
const parsed = invocation.parsed;
|
||||
@@ -2837,7 +2863,69 @@ export async function runSshCommandCapture(config: UniDeskConfig, target: string
|
||||
const stdin = parsed.stdinPrefix !== undefined || parsed.stdinSuffix !== undefined
|
||||
? `${parsed.stdinPrefix ?? ""}${input ?? ""}${parsed.stdinSuffix ?? ""}`
|
||||
: input;
|
||||
return await runSshCaptureRemoteCommand(config, invocation, parsed.remoteCommand, stdin);
|
||||
const plan = sshCaptureBackendPlan(config, process.env);
|
||||
if (plan.backend === "remote-frontend-websocket" && plan.remoteHost !== null) {
|
||||
return await runRemoteSshCapture(config, plan.remoteHost, target, args, stdin);
|
||||
}
|
||||
const local = await runSshCaptureRemoteCommand(config, invocation, parsed.remoteCommand, stdin);
|
||||
const fallbackHost = sshCaptureRemoteHost(config, process.env);
|
||||
if (local.exitCode !== 0 && fallbackHost !== null && isLocalSshCaptureBackendUnavailable(local)) {
|
||||
return await runRemoteSshCapture(config, fallbackHost, target, args, stdin);
|
||||
}
|
||||
return local;
|
||||
}
|
||||
|
||||
async function runRemoteSshCapture(config: UniDeskConfig, host: string, target: string, args: string[], input?: string): Promise<SshCaptureResult> {
|
||||
const { runRemoteSshCommandCapture } = await import("./remote");
|
||||
return await runRemoteSshCommandCapture(config, host, target, args, input);
|
||||
}
|
||||
|
||||
export function sshCaptureBackendPlan(config: UniDeskConfig, env: NodeJS.ProcessEnv = process.env): SshCaptureBackendPlan {
|
||||
const localBackendCore = detectLocalBackendCoreStatus();
|
||||
const remoteHost = sshCaptureRemoteHost(config, env);
|
||||
const runnerEnv = isRunnerEnvironment(env);
|
||||
if (runnerEnv && remoteHost !== null) return { backend: "remote-frontend-websocket", remoteHost, reason: "runner-environment", localBackendCore };
|
||||
if (!localBackendCore.backendCoreContainer && remoteHost !== null) return { backend: "remote-frontend-websocket", remoteHost, reason: "local-backend-core-unavailable", localBackendCore };
|
||||
return { backend: "local-backend-core-broker", remoteHost: null, reason: "main-server-local-backend-core", localBackendCore };
|
||||
}
|
||||
|
||||
function sshCaptureRemoteHost(config: UniDeskConfig, env: NodeJS.ProcessEnv): string | null {
|
||||
return normalizeRemoteHost(env.UNIDESK_MAIN_SERVER_IP)
|
||||
?? normalizeRemoteHost(env.UNIDESK_MAIN_SERVER_HOST)
|
||||
?? normalizeRemoteHost(env.CODE_QUEUE_DEV_CONTAINER_MASTER_HOST)
|
||||
?? normalizeRemoteHost(config.network.publicHost);
|
||||
}
|
||||
|
||||
function normalizeRemoteHost(raw: string | undefined): string | null {
|
||||
const value = raw?.trim() ?? "";
|
||||
if (value.length === 0 || value === "localhost" || value === "127.0.0.1" || value === "::1") return null;
|
||||
return value.replace(/\/+$/u, "");
|
||||
}
|
||||
|
||||
function isRunnerEnvironment(env: NodeJS.ProcessEnv): boolean {
|
||||
return Boolean(
|
||||
env.AGENTRUN_BOOT_MODE
|
||||
|| env.AGENTRUN_RUN_ID
|
||||
|| env.AGENTRUN_K8S_JOB_NAME
|
||||
|| env.CODE_QUEUE_SERVICE_ROLE
|
||||
|| env.CODE_QUEUE_INSTANCE_ID
|
||||
|| env.KUBERNETES_SERVICE_HOST,
|
||||
);
|
||||
}
|
||||
|
||||
function detectLocalBackendCoreStatus(): SshCaptureBackendPlan["localBackendCore"] {
|
||||
const result = spawnSync("docker", ["ps", "--format", "{{.Names}}"], { encoding: "utf8", timeout: 2000 });
|
||||
if (result.error !== undefined) return { dockerExecutable: false, backendCoreContainer: false, error: result.error.message };
|
||||
const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`.trim();
|
||||
return {
|
||||
dockerExecutable: result.status === 0,
|
||||
backendCoreContainer: result.status === 0 && String(result.stdout ?? "").split(/\r?\n/u).includes("unidesk-backend-core"),
|
||||
error: result.status === 0 ? null : output || `docker ps exited ${result.status ?? "unknown"}`,
|
||||
};
|
||||
}
|
||||
|
||||
function isLocalSshCaptureBackendUnavailable(result: SshCaptureResult): boolean {
|
||||
return /No such container: unidesk-backend-core|failed to start broker|Executable not found.*"docker"|docker: not found|Cannot connect to the Docker daemon/iu.test(result.stderr);
|
||||
}
|
||||
|
||||
function writeChunkedStdin(stdin: NodeJS.WritableStream, input: string): void {
|
||||
@@ -2852,6 +2940,10 @@ function writeChunkedStdin(stdin: NodeJS.WritableStream, input: string): void {
|
||||
export async function runSsh(config: UniDeskConfig, providerId: string, args: string[]): Promise<number> {
|
||||
const normalizedArgs = normalizeSshOperationArgs(args);
|
||||
process.stderr.write(sshRouteSeparatorCompatibilityHint(args, normalizedArgs));
|
||||
const plan = sshCaptureBackendPlan(config, process.env);
|
||||
if (plan.backend === "remote-frontend-websocket" && plan.remoteHost !== null) {
|
||||
return await runRemoteSsh(config, plan.remoteHost, providerId, normalizedArgs);
|
||||
}
|
||||
const invocation = parseSshInvocation(providerId, normalizedArgs);
|
||||
const parsed = invocation.parsed;
|
||||
const operationName = normalizedArgs[0] ?? "";
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { sshCaptureBackendPlan } from "./src/ssh";
|
||||
|
||||
function assertCondition(condition: unknown, message: string, detail: unknown = {}): void {
|
||||
if (!condition) throw new Error(`${message}: ${JSON.stringify(detail)}`);
|
||||
}
|
||||
|
||||
const baseConfig = {
|
||||
network: { publicHost: "74.48.78.17" },
|
||||
} as Parameters<typeof sshCaptureBackendPlan>[0];
|
||||
|
||||
const runnerPlan = sshCaptureBackendPlan(baseConfig, {
|
||||
KUBERNETES_SERVICE_HOST: "10.43.0.1",
|
||||
});
|
||||
|
||||
assertCondition(
|
||||
runnerPlan.backend === "remote-frontend-websocket" && runnerPlan.remoteHost === "74.48.78.17" && runnerPlan.reason === "runner-environment",
|
||||
"ssh capture should use frontend websocket in runner environments",
|
||||
runnerPlan,
|
||||
);
|
||||
|
||||
const explicitHostPlan = sshCaptureBackendPlan(baseConfig, {
|
||||
UNIDESK_MAIN_SERVER_IP: "http://74.48.78.17:18081/",
|
||||
KUBERNETES_SERVICE_HOST: "10.43.0.1",
|
||||
});
|
||||
|
||||
assertCondition(
|
||||
explicitHostPlan.backend === "remote-frontend-websocket" && explicitHostPlan.remoteHost === "http://74.48.78.17:18081",
|
||||
"ssh capture should prefer explicit main server host hints and trim trailing slash",
|
||||
explicitHostPlan,
|
||||
);
|
||||
|
||||
const localOnlyPlan = sshCaptureBackendPlan({ network: { publicHost: "127.0.0.1" } } as Parameters<typeof sshCaptureBackendPlan>[0], {});
|
||||
|
||||
assertCondition(
|
||||
localOnlyPlan.backend === "local-backend-core-broker" && localOnlyPlan.remoteHost === null,
|
||||
"ssh capture should keep local backend-core broker mode when no remote host is configured",
|
||||
localOnlyPlan,
|
||||
);
|
||||
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
checks: [
|
||||
"runner environments use remote frontend websocket capture",
|
||||
"explicit main server host hints are preferred and normalized",
|
||||
"local-only environments keep local backend-core broker mode",
|
||||
],
|
||||
}));
|
||||
Reference in New Issue
Block a user