This commit is contained in:
lyon
2026-05-06 07:37:35 +08:00
parent ef70ca972b
commit 4fdca29df3
6 changed files with 263 additions and 11 deletions
+11 -1
View File
@@ -7,6 +7,7 @@ import { jobWithTail, listJobs, readJob, runJob } from "./src/jobs";
import { runChecks } from "./src/check";
import { runSsh } from "./src/ssh";
import { extractRemoteCliOptions, runRemoteCli } from "./src/remote";
import { runMicroserviceCommand } from "./src/microservices";
const remoteOptions = extractRemoteCliOptions(process.argv.slice(2));
const args = remoteOptions.args;
@@ -27,10 +28,14 @@ function help(): unknown {
{ command: "server logs [--tail-bytes N]", description: "Return bounded tails from file logs and docker logs." },
{ command: "server rebuild <backend-core|frontend|provider-gateway>", description: "Build first, then label-replace one service without Docker Compose v1 recreate fallback." },
{ command: "ssh <providerId> [ssh-like args...]", description: "Open a Host SSH / WSL SSH maintenance session through the provider-gateway bridge." },
{ command: "microservice list", description: "List UniDesk-managed microservices and their provider/runtime mapping." },
{ command: "microservice status <id>", description: "Show one microservice config, repository reference, backend mapping, and runtime status." },
{ command: "microservice health <id>", description: "Probe one microservice through backend-core -> provider-gateway HTTP proxy." },
{ command: "microservice proxy <id> <path>", description: "GET a private microservice backend path through the same frontend-only proxy used by WebUI." },
{ 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|host.ssh|echo] [--wait-ms N]", description: "Submit a real internal-core dispatch request for CLI debugging." },
{ command: "debug dispatch [providerId] [docker.ps|provider.upgrade|host.ssh|microservice.http|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." },
],
@@ -151,6 +156,11 @@ async function main(): Promise<void> {
}
}
if (top === "microservice") {
emitJson(commandName, await runMicroserviceCommand(config, args.slice(1)));
return;
}
if (top === "job") {
if (sub === "list") {
emitJson(commandName, { jobs: listJobs() });
+1 -1
View File
@@ -1,7 +1,7 @@
import { runCommand } from "./command";
import { type UniDeskConfig, repoRoot } from "./config";
export const dispatchCommands = ["docker.ps", "provider.upgrade", "host.ssh", "echo"] as const;
export const dispatchCommands = ["docker.ps", "provider.upgrade", "host.ssh", "microservice.http", "echo"] as const;
export type DebugDispatchCommand = typeof dispatchCommands[number];
export function isDebugDispatchCommand(value: unknown): value is DebugDispatchCommand {
+27 -1
View File
@@ -354,6 +354,28 @@ async function remoteDebugTask(session: FrontendSession, args: string[]): Promis
return { transport: "frontend", tasksResponse, taskId, task: task ?? null };
}
async function remoteMicroservice(session: FrontendSession, args: string[]): Promise<unknown> {
const action = args[1] ?? "list";
const id = args[2];
const path = args[3];
if (action === "list") {
return { transport: "frontend", response: await frontendJson(session, "/api/microservices", undefined, 12_000) };
}
if ((action === "status" || action === "health") && id !== undefined) {
return {
transport: "frontend",
response: await frontendJson(session, `/api/microservices/${encodeURIComponent(id)}/${action}`, undefined, 18_000),
};
}
if (action === "proxy" && id !== undefined && path !== undefined && path.startsWith("/")) {
return {
transport: "frontend",
response: await frontendJson(session, `/api/microservices/${encodeURIComponent(id)}/proxy${path}`, undefined, 24_000),
};
}
throw new Error("remote microservice command must be: microservice list | status <id> | health <id> | proxy <id> <path>");
}
async function runRemoteSshOverFrontend(session: FrontendSession, providerId: string | undefined, args: string[]): Promise<number> {
if (!providerId) throw new Error("remote ssh requires provider id, for example: bun scripts/cli.ts --main-server-ip 74.48.78.17 ssh D601 hostname");
const parsed = parseSshArgs(args);
@@ -399,7 +421,7 @@ async function runRemoteCliOverFrontend(options: RemoteCliOptions, config: UniDe
emitRemoteJson(name, {
transport: "frontend",
baseUrl: session.baseUrl,
commands: ["debug health", "debug dispatch", "debug task", "ssh <providerId> <command>"],
commands: ["debug health", "debug dispatch", "debug task", "ssh <providerId> <command>", "microservice list", "microservice status <id>", "microservice health <id>", "microservice proxy <id> <path>"],
});
return 0;
}
@@ -415,6 +437,10 @@ async function runRemoteCliOverFrontend(options: RemoteCliOptions, config: UniDe
emitRemoteJson(name, await remoteDebugTask(session, args));
return 0;
}
if (top === "microservice") {
emitRemoteJson(name, await remoteMicroservice(session, args));
return 0;
}
if (top === "ssh") {
return await runRemoteSshOverFrontend(session, sub, args.slice(2));
}
+4 -1
View File
@@ -744,11 +744,14 @@ async function dispatchTask(req: Request): Promise<Response> {
return jsonResponse({ ok: false, error: "providerId is required" }, 400);
}
if (command === null) {
return jsonResponse({ ok: false, error: "command must be one of docker.ps, provider.upgrade, host.ssh, echo" }, 400);
return jsonResponse({ ok: false, error: "command must be one of docker.ps, provider.upgrade, host.ssh, microservice.http, echo" }, 400);
}
if (command === "host.ssh" && !(await providerSupports(providerId, "host.ssh"))) {
return jsonResponse({ ok: false, error: `provider does not declare host.ssh capability: ${providerId}` }, 409);
}
if (command === "microservice.http" && !(await providerSupports(providerId, "microservice.http"))) {
return jsonResponse({ ok: false, error: `provider does not declare microservice.http capability: ${providerId}` }, 409);
}
const taskId = `task_${Date.now()}_${Math.random().toString(16).slice(2)}`;
await sql`
INSERT INTO unidesk_tasks (id, provider_id, command, status, payload, result)
+218 -5
View File
@@ -193,7 +193,7 @@ function sendJson(value: unknown): void {
}
function sendRegister(): void {
const capabilities = ["heartbeat", "system.status", "docker.status", "docker.ps", "provider.upgrade", "echo"];
const capabilities = ["heartbeat", "system.status", "docker.status", "docker.ps", "provider.upgrade", "microservice.http", "echo"];
if (isHostSshConfigured()) capabilities.push("host.ssh");
sendJson({
type: "register",
@@ -609,6 +609,45 @@ function defaultHostSshProbeCommand(): string {
return "printf 'UNIDESK_SSH_TEST user=%s host=%s bridge=%s cwd=%s\\n' \"$(whoami)\" \"$(hostname)\" \"${UNIDESK_BRIDGE:-}\" \"$(pwd)\"";
}
function normalizeShellCommand(command: string): string {
return command.replace(/\\\r?\n/g, " ").replace(/\s+/g, " ").trim().toLowerCase();
}
function hostSshSelfMutationReason(command: string, cwd: string | null): string | null {
const normalized = normalizeShellCommand(command);
const currentContainerName = `unidesk-provider-gateway-${safeDockerName(config.providerId)}`.toLowerCase();
const composeProject = config.upgradeComposeProject.toLowerCase();
const composeService = config.upgradeService.toLowerCase();
const composeFile = config.upgradeComposeFile.toLowerCase();
const composeEnvFile = config.upgradeEnvFile.toLowerCase();
const hostProjectRoot = config.upgradeHostProjectRoot.toLowerCase();
const currentCwd = (cwd ?? "").toLowerCase();
const mutatesCompose = /\bdocker\s+compose\b|\bdocker-compose\b/.test(normalized)
&& /\b(up|build|restart|stop|rm|down|kill)\b/.test(normalized);
const mentionsCurrentProvider = normalized.includes(currentContainerName)
|| normalized.includes(composeProject)
|| normalized.includes(composeService)
|| normalized.includes(composeFile)
|| normalized.includes(composeEnvFile)
|| normalized.includes(hostProjectRoot)
|| (currentCwd.length > 0 && currentCwd.startsWith(hostProjectRoot));
if (mutatesCompose && mentionsCurrentProvider) {
return "docker compose mutation targets the current provider-gateway deployment";
}
const mutatesContainer = /\bdocker\s+(container\s+)?(rm|stop|restart|kill)\b/.test(normalized);
if (mutatesContainer && normalized.includes(currentContainerName)) {
return "docker container mutation targets the current provider-gateway container";
}
return null;
}
function assertHostSshCommandAllowed(command: string, cwd: string | null): void {
const reason = hostSshSelfMutationReason(command, cwd);
if (reason !== null) {
throw new Error(`blocked unsafe host.ssh self-mutation: ${reason}; use provider.upgrade mode=schedule or a detached local node shell instead`);
}
}
async function runHostSsh(payload: Record<string, JsonValue>): Promise<JsonValue> {
if (!isHostSshConfigured()) {
throw new Error(`host SSH bridge is not configured; missing ${missingHostSshFields().join(", ")}`);
@@ -629,6 +668,7 @@ async function runHostSsh(payload: Record<string, JsonValue>): Promise<JsonValue
throw new Error(`host SSH command is too long: ${command.length} bytes`);
}
const cwd = payloadString(payload, "cwd") ?? config.hostRemoteCwd;
if (mode === "exec") assertHostSshCommandAllowed(command, cwd);
const scriptParts = [
"set -e",
cwd === null ? null : `cd ${shellQuote(cwd)}`,
@@ -682,6 +722,7 @@ async function runHostSsh(payload: Record<string, JsonValue>): Promise<JsonValue
function hostSshRemoteScript(command: string | null, cwd: string | null, cols?: number, rows?: number): string {
const fallbackCwd = config.hostRemoteCwd ?? `/home/${config.hostSshUser ?? "root"}`;
const requestedCwd = cwd ?? fallbackCwd;
if (command !== null && command.length > 0) assertHostSshCommandAllowed(command, requestedCwd);
const loginShell = config.hostLoginShell ?? "/bin/bash";
const resize = Number.isFinite(cols) && Number.isFinite(rows)
? `stty rows ${Math.max(8, Math.min(120, Math.floor(rows ?? 30)))} cols ${Math.max(20, Math.min(300, Math.floor(cols ?? 100)))} 2>/dev/null || true`
@@ -846,7 +887,7 @@ function safeDockerName(value: string): string {
function upgradePlan(taskId: string): Record<string, JsonValue> {
const workspace = config.upgradeWorkspacePath;
const composeCommand = [
const composeBaseCommand = [
"docker",
"compose",
"--env-file",
@@ -855,14 +896,39 @@ function upgradePlan(taskId: string): Record<string, JsonValue> {
`${workspace}/${config.upgradeComposeFile}`,
"-p",
config.upgradeComposeProject,
];
const composeBuildCommand = [
...composeBaseCommand,
"build",
config.upgradeService,
];
const listServiceContainersCommand = [
"docker",
"ps",
"-aq",
"--filter",
`label=com.docker.compose.project=${config.upgradeComposeProject}`,
"--filter",
`label=com.docker.compose.service=${config.upgradeService}`,
];
const composeUpCommand = [
...composeBaseCommand,
"up",
"-d",
"--no-deps",
"--build",
"--force-recreate",
config.upgradeService,
];
const updaterName = `unidesk-provider-upgrader-${safeDockerName(taskId)}`;
const script = `set -eu; sleep 2; cd ${shellQuote(workspace)}; ${composeCommand.map(shellQuote).join(" ")}`;
const script = [
"set -eu",
"sleep 2",
`cd ${shellQuote(workspace)}`,
composeBuildCommand.map(shellQuote).join(" "),
`ids=$(${listServiceContainersCommand.map(shellQuote).join(" ")})`,
`if [ -n "$ids" ]; then docker rm -f $ids; fi`,
composeUpCommand.map(shellQuote).join(" "),
].join("; ");
const dockerRunCommand = [
"docker",
"run",
@@ -894,7 +960,19 @@ function upgradePlan(taskId: string): Record<string, JsonValue> {
composeFile: config.upgradeComposeFile,
envFile: config.upgradeEnvFile,
},
composeCommand,
composeCommand: composeUpCommand,
composeBuildCommand,
listServiceContainersCommand,
composeUpCommand,
replacementStrategy: {
buildBeforeRemove: true,
removeScope: {
projectLabel: config.upgradeComposeProject,
serviceLabel: config.upgradeService,
},
noDeps: true,
namedVolumesPreserved: true,
},
dockerRunCommand,
};
}
@@ -917,6 +995,132 @@ async function runProviderUpgrade(taskId: string, payload: Record<string, JsonVa
};
}
function payloadNumber(payload: Record<string, JsonValue>, key: string, fallback: number): number {
const raw = payload[key];
const value = typeof raw === "number" ? raw : typeof raw === "string" ? Number(raw) : fallback;
if (!Number.isFinite(value) || value <= 0) return fallback;
return Math.floor(value);
}
function payloadJsonArrayLimits(payload: Record<string, JsonValue>): Record<string, number> {
const raw = payload.jsonArrayLimits;
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {};
const limits: Record<string, number> = {};
for (const [path, value] of Object.entries(raw)) {
if (!/^[A-Za-z0-9_.-]+$/.test(path)) continue;
const limit = typeof value === "number" ? value : typeof value === "string" ? Number(value) : NaN;
if (Number.isInteger(limit) && limit > 0 && limit <= 500) limits[path] = limit;
}
return limits;
}
function assertAllowedMicroserviceBase(rawBaseUrl: string): URL {
const baseUrl = new URL(rawBaseUrl);
if (baseUrl.protocol !== "http:") throw new Error(`microservice backend only supports http URLs, got ${baseUrl.protocol}`);
const host = baseUrl.hostname.toLowerCase();
const allowedHosts = new Set(["127.0.0.1", "localhost", "host.docker.internal"]);
if (!allowedHosts.has(host)) throw new Error(`microservice backend host is not allowed: ${baseUrl.hostname}`);
return baseUrl;
}
function arrayAtPath(value: unknown, path: string): JsonValue[] | null {
let current: unknown = value;
for (const part of path.split(".")) {
if (typeof current !== "object" || current === null || Array.isArray(current)) return null;
current = (current as Record<string, unknown>)[part];
}
return Array.isArray(current) ? current as JsonValue[] : null;
}
function applyJsonArrayLimits(bodyText: string, contentType: string, limits: Record<string, number>): { bodyText: string; transform: JsonValue } {
const entries = Object.entries(limits);
if (entries.length === 0) return { bodyText, transform: { applied: false } };
if (!contentType.toLowerCase().includes("json")) {
return { bodyText, transform: { applied: false, reason: "content-type is not json" } };
}
try {
const parsed = JSON.parse(bodyText) as unknown;
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
return { bodyText, transform: { applied: false, reason: "json root is not an object" } };
}
const root = parsed as Record<string, unknown>;
const applied: Record<string, JsonValue> = {};
for (const [path, limit] of entries) {
const array = arrayAtPath(root, path);
if (array === null) continue;
const originalLength = array.length;
if (array.length > limit) array.splice(limit);
applied[path] = { limit, originalLength, returnedLength: array.length };
}
root._unidesk = { arrayLimits: applied };
return { bodyText: JSON.stringify(parsed), transform: { applied: Object.keys(applied).length > 0, arrayLimits: applied } };
} catch (error) {
return { bodyText, transform: { applied: false, error: error instanceof Error ? error.message : String(error) } };
}
}
async function runMicroserviceHttp(payload: Record<string, JsonValue>): Promise<JsonValue> {
const rawMethod = String(payload.method || "GET").toUpperCase();
const allowedMethods = new Set(["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"]);
const method = allowedMethods.has(rawMethod) ? rawMethod : "GET";
const targetBaseUrl = payloadString(payload, "targetBaseUrl");
if (targetBaseUrl === null) throw new Error("microservice.http requires targetBaseUrl");
const path = payloadString(payload, "path") ?? "/";
const query = payloadString(payload, "query") ?? "";
if (!path.startsWith("/")) throw new Error("microservice.http path must start with /");
if (query.length > 0 && !query.startsWith("?")) throw new Error("microservice.http query must start with ?");
const baseUrl = assertAllowedMicroserviceBase(targetBaseUrl);
const url = new URL(path, baseUrl);
url.search = query;
const timeoutMs = Math.max(1000, Math.min(30_000, payloadNumber(payload, "timeoutMs", 10_000)));
const jsonArrayLimits = payloadJsonArrayLimits(payload);
const bodyText = payloadString(payload, "bodyText") ?? "";
const requestHeaders = typeof payload.requestHeaders === "object" && payload.requestHeaders !== null && !Array.isArray(payload.requestHeaders)
? payload.requestHeaders as Record<string, JsonValue>
: {};
const headers = new Headers();
const contentType = typeof requestHeaders["content-type"] === "string" ? requestHeaders["content-type"] : "";
if (contentType.length > 0) headers.set("content-type", contentType);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(url, {
method,
headers,
body: method === "GET" || method === "HEAD" ? undefined : bodyText,
signal: controller.signal,
});
const rawBodyText = await response.text();
const contentType = response.headers.get("content-type") ?? "text/plain; charset=utf-8";
const transformed = applyJsonArrayLimits(rawBodyText, contentType, jsonArrayLimits);
return {
ok: true,
serviceId: payloadString(payload, "serviceId") ?? "unknown",
method,
url: url.toString(),
status: response.status,
upstreamOk: response.ok,
contentType,
bodyText: truncateText(transformed.bodyText, 1024 * 1024),
upstreamBodyBytes: rawBodyText.length,
returnedBodyBytes: Math.min(transformed.bodyText.length, 1024 * 1024),
truncated: transformed.bodyText.length > 1024 * 1024,
transform: transformed.transform,
};
} catch (error) {
return {
ok: false,
serviceId: payloadString(payload, "serviceId") ?? "unknown",
method,
url: url.toString(),
timeoutMs,
error: error instanceof Error ? error.message : String(error),
};
} finally {
clearTimeout(timer);
}
}
async function handleDispatch(message: CoreDispatchMessage): Promise<void> {
logger("info", "dispatch_received", { taskId: message.taskId, command: message.command, payload: message.payload });
await sendTaskStatus(message.taskId, "accepted", "provider accepted task");
@@ -941,6 +1145,15 @@ async function handleDispatch(message: CoreDispatchMessage): Promise<void> {
await sendTaskStatus(message.taskId, "succeeded", "host SSH command completed", result);
return;
}
if (message.command === "microservice.http") {
const result = await runMicroserviceHttp(message.payload);
if ((result as { ok?: unknown }).ok !== true) {
await sendTaskStatus(message.taskId, "failed", "microservice HTTP proxy failed", result);
return;
}
await sendTaskStatus(message.taskId, "succeeded", "microservice HTTP proxy completed", result);
return;
}
await sendTaskStatus(message.taskId, "succeeded", "echo completed", { echo: message.payload });
} catch (error) {
const text = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
+2 -2
View File
@@ -106,7 +106,7 @@ export interface ProviderTaskStatusMessage {
result?: JsonValue;
}
export type ProviderDispatchCommand = "docker.ps" | "provider.upgrade" | "host.ssh" | "echo";
export type ProviderDispatchCommand = "docker.ps" | "provider.upgrade" | "host.ssh" | "microservice.http" | "echo";
export interface CoreDispatchMessage {
type: "dispatch";
@@ -271,7 +271,7 @@ export function parseJsonObject(value: string, name: string): Record<string, Jso
}
export function isProviderDispatchCommand(value: unknown): value is ProviderDispatchCommand {
return value === "docker.ps" || value === "provider.upgrade" || value === "host.ssh" || value === "echo";
return value === "docker.ps" || value === "provider.upgrade" || value === "host.ssh" || value === "microservice.http" || value === "echo";
}
export function isProviderToCoreMessage(value: unknown): value is ProviderToCoreMessage {