feat: add provider ssh bridge
This commit is contained in:
+54
-5
@@ -1,10 +1,11 @@
|
||||
import { readConfig } from "./src/config";
|
||||
import { debugDispatch, debugHealth } from "./src/debug";
|
||||
import { debugDispatch, debugHealth, debugTask, isDebugDispatchCommand, type DebugDispatchCommand } from "./src/debug";
|
||||
import { stackLogs, stackStatus, startStack, stopStack } from "./src/docker";
|
||||
import { runE2E } from "./src/e2e";
|
||||
import { emitError, emitJson } from "./src/output";
|
||||
import { jobWithTail, listJobs, readJob, runJob } from "./src/jobs";
|
||||
import { runChecks } from "./src/check";
|
||||
import { runSsh } from "./src/ssh";
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const commandName = args.join(" ") || "help";
|
||||
@@ -21,10 +22,12 @@ function help(): unknown {
|
||||
{ command: "server stop", description: "Fire-and-forget docker-compose down for the fixed UniDesk stack." },
|
||||
{ command: "server status", description: "Show fixed ports, containers, service health, and public URLs." },
|
||||
{ command: "server logs [--tail-bytes N]", description: "Return bounded tails from file logs and docker logs." },
|
||||
{ command: "ssh <providerId> [ssh-like args...]", description: "Open a Host SSH / WSL SSH maintenance session through the provider-gateway bridge." },
|
||||
{ command: "job list", description: "List async jobs from .state/jobs." },
|
||||
{ command: "job status <jobId|latest> [--tail-bytes N]", description: "Show job state with bounded stdout/stderr tails." },
|
||||
{ command: "debug health", description: "Probe internal core, nodes, system/Docker status, frontend, provider ingress, and public boundary." },
|
||||
{ command: "debug dispatch [providerId] [docker.ps|provider.upgrade|echo]", description: "Submit a real internal-core dispatch request for CLI debugging." },
|
||||
{ command: "debug dispatch [providerId] [docker.ps|provider.upgrade|host.ssh|echo] [--wait-ms N]", description: "Submit a real internal-core dispatch request for CLI debugging." },
|
||||
{ command: "debug task <taskId|latest>", description: "Read a dispatched task record from internal core for CLI debugging." },
|
||||
{ command: "e2e run", description: "Run public frontend/provider, internal core/database, and Playwright login E2E checks." },
|
||||
],
|
||||
};
|
||||
@@ -39,6 +42,41 @@ function numberOption(name: string, defaultValue: number): number {
|
||||
return value;
|
||||
}
|
||||
|
||||
function stringOption(name: string): string | undefined {
|
||||
const index = args.indexOf(name);
|
||||
if (index === -1) return undefined;
|
||||
const raw = args[index + 1];
|
||||
if (raw === undefined || raw.length === 0) throw new Error(`${name} requires a non-empty value`);
|
||||
return raw;
|
||||
}
|
||||
|
||||
function jsonOption(name: string): Record<string, unknown> | undefined {
|
||||
const raw = stringOption(name);
|
||||
if (raw === undefined) return undefined;
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error(`${name} must be a JSON object`);
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function dispatchPayload(command: DebugDispatchCommand): Record<string, unknown> {
|
||||
const explicit = jsonOption("--payload-json") ?? {};
|
||||
if (command === "provider.upgrade") {
|
||||
return { source: "cli-debug", mode: stringOption("--mode") ?? stringOption("--upgrade-mode") ?? "plan", ...explicit };
|
||||
}
|
||||
if (command === "host.ssh") {
|
||||
const sshCommand = stringOption("--ssh-command");
|
||||
return {
|
||||
source: "cli-debug",
|
||||
mode: sshCommand === undefined ? "probe" : "exec",
|
||||
...(sshCommand === undefined ? {} : { command: sshCommand }),
|
||||
...(stringOption("--cwd") === undefined ? {} : { cwd: stringOption("--cwd") }),
|
||||
...(args.includes("--timeout-ms") ? { timeoutMs: numberOption("--timeout-ms", 8000) } : {}),
|
||||
...explicit,
|
||||
};
|
||||
}
|
||||
return { source: "cli-debug", ...explicit };
|
||||
}
|
||||
|
||||
function latestJobId(): string {
|
||||
const jobs = listJobs();
|
||||
if (jobs.length === 0) throw new Error("No jobs found");
|
||||
@@ -60,6 +98,12 @@ async function main(): Promise<void> {
|
||||
|
||||
const config = readConfig();
|
||||
|
||||
if (top === "ssh") {
|
||||
const exitCode = await runSsh(config, sub ?? "", args.slice(2));
|
||||
process.exitCode = exitCode;
|
||||
return;
|
||||
}
|
||||
|
||||
if (top === "config" && sub === "show") {
|
||||
emitJson(commandName, { config });
|
||||
return;
|
||||
@@ -109,9 +153,14 @@ async function main(): Promise<void> {
|
||||
return;
|
||||
}
|
||||
if (sub === "dispatch") {
|
||||
const providerId = third ?? config.providerGateway.id;
|
||||
const dispatchCommand = fourth === "docker.ps" || fourth === "provider.upgrade" || fourth === "echo" ? fourth : "docker.ps";
|
||||
emitJson(commandName, await debugDispatch(config, providerId, dispatchCommand));
|
||||
const providerId = isDebugDispatchCommand(third) ? config.providerGateway.id : third ?? config.providerGateway.id;
|
||||
const commandArg = isDebugDispatchCommand(third) ? third : fourth;
|
||||
const dispatchCommand = isDebugDispatchCommand(commandArg) ? commandArg : "docker.ps";
|
||||
emitJson(commandName, await debugDispatch(config, providerId, dispatchCommand, dispatchPayload(dispatchCommand), numberOption("--wait-ms", 0)));
|
||||
return;
|
||||
}
|
||||
if (sub === "task") {
|
||||
emitJson(commandName, await debugTask(config, third ?? "latest"));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
+42
-5
@@ -1,6 +1,13 @@
|
||||
import { runCommand } from "./command";
|
||||
import { type UniDeskConfig, repoRoot } from "./config";
|
||||
|
||||
export const dispatchCommands = ["docker.ps", "provider.upgrade", "host.ssh", "echo"] as const;
|
||||
export type DebugDispatchCommand = typeof dispatchCommands[number];
|
||||
|
||||
export function isDebugDispatchCommand(value: unknown): value is DebugDispatchCommand {
|
||||
return dispatchCommands.includes(value as DebugDispatchCommand);
|
||||
}
|
||||
|
||||
async function readJson(url: string, init?: RequestInit): Promise<unknown> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 5000);
|
||||
@@ -134,9 +141,39 @@ export async function debugHealth(config: UniDeskConfig): Promise<unknown> {
|
||||
};
|
||||
}
|
||||
|
||||
export async function debugDispatch(config: UniDeskConfig, providerId: string, command: "docker.ps" | "provider.upgrade" | "echo"): Promise<unknown> {
|
||||
return coreInternalFetch("/api/dispatch", {
|
||||
method: "POST",
|
||||
body: { providerId, command, payload: command === "provider.upgrade" ? { source: "cli-debug", mode: "plan" } : { source: "cli-debug" } },
|
||||
});
|
||||
async function waitForTask(taskId: string, timeoutMs: number): Promise<unknown> {
|
||||
const started = Date.now();
|
||||
let latest: unknown = null;
|
||||
while (Date.now() - started < timeoutMs) {
|
||||
latest = coreInternalFetch("/api/tasks?limit=100");
|
||||
const tasks = (latest as { body?: { tasks?: Array<{ id?: string; status?: string; result?: unknown }> } }).body?.tasks ?? [];
|
||||
const task = tasks.find((item) => item.id === taskId);
|
||||
if (task?.status === "succeeded" || task?.status === "failed") return { ok: true, task };
|
||||
await Bun.sleep(500);
|
||||
}
|
||||
return { ok: false, timeoutMs, latest };
|
||||
}
|
||||
|
||||
export async function debugTask(_config: UniDeskConfig, taskId: string): Promise<unknown> {
|
||||
const tasksResponse = coreInternalFetch("/api/tasks?limit=100");
|
||||
const tasks = (tasksResponse as { body?: { tasks?: Array<{ id?: string }> } }).body?.tasks ?? [];
|
||||
const task = taskId === "latest" ? tasks[0] : tasks.find((item) => item.id === taskId);
|
||||
return { tasksResponse, taskId, task: task ?? null };
|
||||
}
|
||||
|
||||
export async function debugDispatch(
|
||||
config: UniDeskConfig,
|
||||
providerId: string,
|
||||
command: DebugDispatchCommand,
|
||||
payload?: Record<string, unknown>,
|
||||
waitMs = 0,
|
||||
): Promise<unknown> {
|
||||
const dispatchPayload = payload ?? (command === "provider.upgrade" ? { source: "cli-debug", mode: "plan" } : { source: "cli-debug" });
|
||||
const dispatch = coreInternalFetch("/api/dispatch", {
|
||||
method: "POST",
|
||||
body: { providerId, command, payload: dispatchPayload },
|
||||
});
|
||||
const taskId = (dispatch as { body?: { taskId?: string } }).body?.taskId ?? "";
|
||||
const wait = waitMs > 0 && taskId.length > 0 ? await waitForTask(taskId, waitMs) : null;
|
||||
return { dispatch, wait };
|
||||
}
|
||||
|
||||
@@ -95,6 +95,7 @@ export function writeComposeEnv(config: UniDeskConfig, freshLogPrefix: boolean):
|
||||
UNIDESK_PROVIDER_UPGRADE_RUNNER_IMAGE: config.providerGateway.upgrade.runnerImage,
|
||||
UNIDESK_LOG_DIR: logDir,
|
||||
UNIDESK_LOG_PREFIX: logPrefix,
|
||||
UNIDESK_HOST_SSH_KEY_DIR: config.sshForwarding.keyDir,
|
||||
UNIDESK_HOST_SSH_HOST: config.sshForwarding.host,
|
||||
UNIDESK_HOST_SSH_PORT: String(config.sshForwarding.port),
|
||||
UNIDESK_HOST_SSH_USER: config.sshForwarding.user,
|
||||
|
||||
@@ -331,6 +331,7 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2
|
||||
await page.getByRole("button", { name: /资源节点/ }).click();
|
||||
await page.getByRole("button", { name: /资源监控/ }).click();
|
||||
await page.waitForSelector('[data-testid="node-monitor-page"]', { timeout: 10000 });
|
||||
await page.locator('[data-testid="node-monitor-page"]').getByRole("button", { name: new RegExp(config.providerGateway.id) }).click();
|
||||
await page.waitForSelector('[data-testid="metric-chart-cpu"]', { timeout: 10000 });
|
||||
await page.waitForSelector('[data-testid="metric-chart-memory"]', { timeout: 10000 });
|
||||
await page.waitForSelector('[data-testid="metric-chart-disk"]', { timeout: 10000 });
|
||||
@@ -344,6 +345,7 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2
|
||||
const upgradeControlText = await page.locator('[data-testid="provider-upgrade-control"]').innerText({ timeout: 5000 });
|
||||
await page.getByRole("button", { name: /Docker 状态/ }).click();
|
||||
await page.waitForSelector('[data-testid="docker-status-page"]', { timeout: 10000 });
|
||||
await page.locator('[data-testid="docker-status-page"]').getByRole("button", { name: new RegExp(config.providerGateway.id) }).click();
|
||||
await page.waitForSelector('[data-testid="docker-container-table"]', { timeout: 10000 });
|
||||
await page.waitForSelector('[data-testid="database-volume-card"]', { timeout: 10000 });
|
||||
await page.waitForFunction(() => {
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { type UniDeskConfig, repoRoot } from "./config";
|
||||
|
||||
interface ParsedSshArgs {
|
||||
remoteCommand: string | null;
|
||||
}
|
||||
|
||||
const sshOptionsWithValue = new Set([
|
||||
"-B", "-b", "-c", "-D", "-E", "-e", "-F", "-I", "-i", "-J", "-L", "-l", "-m", "-O", "-o", "-p", "-Q", "-R", "-S", "-W", "-w",
|
||||
]);
|
||||
|
||||
function parseSshArgs(args: string[]): ParsedSshArgs {
|
||||
const remote: string[] = [];
|
||||
let remoteStarted = false;
|
||||
for (let index = 0; index < args.length; index += 1) {
|
||||
const arg = args[index] ?? "";
|
||||
if (remoteStarted) {
|
||||
remote.push(arg);
|
||||
continue;
|
||||
}
|
||||
if (arg === "--") {
|
||||
remoteStarted = true;
|
||||
continue;
|
||||
}
|
||||
if (arg.startsWith("-") && arg !== "-") {
|
||||
if (sshOptionsWithValue.has(arg) && index + 1 < args.length) index += 1;
|
||||
continue;
|
||||
}
|
||||
remoteStarted = true;
|
||||
remote.push(arg);
|
||||
}
|
||||
return { remoteCommand: remote.length === 0 ? null : remote.join(" ") };
|
||||
}
|
||||
|
||||
function brokerSource(): string {
|
||||
return String.raw`
|
||||
const open = JSON.parse(process.argv[2] || process.argv[1] || "{}");
|
||||
const token = process.env.PROVIDER_TOKEN || "";
|
||||
const url = "ws://127.0.0.1:8080/ws/ssh?token=" + encodeURIComponent(token);
|
||||
const ws = new WebSocket(url);
|
||||
let exitCode = 255;
|
||||
let canSend = false;
|
||||
let opened = false;
|
||||
const pending = [];
|
||||
const openTimer = setTimeout(() => {
|
||||
if (opened) return;
|
||||
process.stderr.write("unidesk ssh bridge timed out waiting for provider session\n");
|
||||
try { ws.close(); } catch {}
|
||||
process.exit(255);
|
||||
}, Number(open.openTimeoutMs || 15000));
|
||||
|
||||
function send(value) {
|
||||
const text = JSON.stringify(value);
|
||||
if (!canSend || ws.readyState !== WebSocket.OPEN) {
|
||||
pending.push(text);
|
||||
return;
|
||||
}
|
||||
ws.send(text);
|
||||
}
|
||||
|
||||
function flush() {
|
||||
while (pending.length > 0 && ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(pending.shift());
|
||||
}
|
||||
}
|
||||
|
||||
function decodeData(data) {
|
||||
return typeof data === "string" ? data : Buffer.from(data).toString("utf8");
|
||||
}
|
||||
|
||||
ws.addEventListener("open", () => {
|
||||
canSend = true;
|
||||
send({
|
||||
type: "ssh.open",
|
||||
providerId: open.providerId,
|
||||
command: open.command || undefined,
|
||||
cwd: open.cwd || undefined,
|
||||
cols: open.cols || 100,
|
||||
rows: open.rows || 30,
|
||||
});
|
||||
flush();
|
||||
});
|
||||
|
||||
ws.addEventListener("message", (event) => {
|
||||
const message = JSON.parse(decodeData(event.data));
|
||||
if (message.type === "ssh.data") {
|
||||
opened = true;
|
||||
const chunk = Buffer.from(message.data || "", "base64");
|
||||
if (message.stream === "stderr") process.stderr.write(chunk);
|
||||
else process.stdout.write(chunk);
|
||||
return;
|
||||
}
|
||||
if (message.type === "ssh.opened") {
|
||||
opened = true;
|
||||
clearTimeout(openTimer);
|
||||
return;
|
||||
}
|
||||
if (message.type === "ssh.dispatched") return;
|
||||
if (message.type === "ssh.error") {
|
||||
clearTimeout(openTimer);
|
||||
process.stderr.write(String(message.message || "ssh bridge error") + "\n");
|
||||
exitCode = 255;
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
if (message.type === "ssh.exit") {
|
||||
clearTimeout(openTimer);
|
||||
exitCode = Number.isInteger(message.exitCode) ? message.exitCode : 255;
|
||||
ws.close();
|
||||
}
|
||||
});
|
||||
|
||||
ws.addEventListener("close", () => {
|
||||
process.exit(exitCode);
|
||||
});
|
||||
|
||||
ws.addEventListener("error", () => {
|
||||
process.stderr.write("unidesk ssh bridge websocket error\n");
|
||||
process.exit(255);
|
||||
});
|
||||
|
||||
process.stdin.on("data", (chunk) => {
|
||||
send({ type: "ssh.input", data: Buffer.from(chunk).toString("base64"), encoding: "base64" });
|
||||
flush();
|
||||
});
|
||||
process.stdin.on("end", () => {
|
||||
send({ type: "ssh.eof" });
|
||||
flush();
|
||||
});
|
||||
`;
|
||||
}
|
||||
|
||||
function terminalSize(): { cols: number; rows: number } {
|
||||
return {
|
||||
cols: Number(process.stdout.columns) > 0 ? Number(process.stdout.columns) : 100,
|
||||
rows: Number(process.stdout.rows) > 0 ? Number(process.stdout.rows) : 30,
|
||||
};
|
||||
}
|
||||
|
||||
export async function runSsh(config: UniDeskConfig, providerId: string, args: string[]): Promise<number> {
|
||||
if (!providerId) throw new Error("ssh requires provider id, for example: bun scripts/cli.ts ssh D518");
|
||||
const parsed = parseSshArgs(args);
|
||||
const size = terminalSize();
|
||||
const payload = {
|
||||
providerId,
|
||||
command: parsed.remoteCommand,
|
||||
cols: size.cols,
|
||||
rows: size.rows,
|
||||
};
|
||||
const child = spawn("docker", [
|
||||
"exec",
|
||||
"-i",
|
||||
"unidesk-backend-core",
|
||||
"bun",
|
||||
"-e",
|
||||
brokerSource(),
|
||||
"--",
|
||||
JSON.stringify(payload),
|
||||
], {
|
||||
cwd: repoRoot,
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
const rawMode = parsed.remoteCommand === null && process.stdin.isTTY;
|
||||
if (rawMode) process.stdin.setRawMode(true);
|
||||
process.stdin.resume();
|
||||
process.stdin.pipe(child.stdin);
|
||||
child.stdout.pipe(process.stdout);
|
||||
child.stderr.pipe(process.stderr);
|
||||
|
||||
return await new Promise<number>((resolve) => {
|
||||
const restore = (): void => {
|
||||
process.stdin.unpipe(child.stdin);
|
||||
if (rawMode) process.stdin.setRawMode(false);
|
||||
};
|
||||
child.on("error", (error) => {
|
||||
restore();
|
||||
process.stderr.write(`unidesk ssh failed to start broker: ${error.message}\n`);
|
||||
resolve(255);
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
restore();
|
||||
resolve(code ?? 255);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user