From 4fdca29df3ea8ebe4741d2b0477de270e3313e0d Mon Sep 17 00:00:00 2001 From: lyon Date: Wed, 6 May 2026 07:37:35 +0800 Subject: [PATCH] update --- scripts/cli.ts | 12 +- scripts/src/debug.ts | 2 +- scripts/src/remote.ts | 28 ++- src/components/backend-core/src/index.ts | 5 +- src/components/provider-gateway/src/index.ts | 223 ++++++++++++++++++- src/components/shared/src/index.ts | 4 +- 6 files changed, 263 insertions(+), 11 deletions(-) diff --git a/scripts/cli.ts b/scripts/cli.ts index 991a31ff..16d08c67 100644 --- a/scripts/cli.ts +++ b/scripts/cli.ts @@ -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 ", description: "Build first, then label-replace one service without Docker Compose v1 recreate fallback." }, { command: "ssh [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 ", description: "Show one microservice config, repository reference, backend mapping, and runtime status." }, + { command: "microservice health ", description: "Probe one microservice through backend-core -> provider-gateway HTTP proxy." }, + { command: "microservice proxy ", 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 [--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 ", 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 { } } + if (top === "microservice") { + emitJson(commandName, await runMicroserviceCommand(config, args.slice(1))); + return; + } + if (top === "job") { if (sub === "list") { emitJson(commandName, { jobs: listJobs() }); diff --git a/scripts/src/debug.ts b/scripts/src/debug.ts index 9d71f57a..82526ef6 100644 --- a/scripts/src/debug.ts +++ b/scripts/src/debug.ts @@ -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 { diff --git a/scripts/src/remote.ts b/scripts/src/remote.ts index f025f8c9..c2acaadf 100644 --- a/scripts/src/remote.ts +++ b/scripts/src/remote.ts @@ -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 { + 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 | health | proxy "); +} + async function runRemoteSshOverFrontend(session: FrontendSession, providerId: string | undefined, args: string[]): Promise { 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 "], + commands: ["debug health", "debug dispatch", "debug task", "ssh ", "microservice list", "microservice status ", "microservice health ", "microservice proxy "], }); 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)); } diff --git a/src/components/backend-core/src/index.ts b/src/components/backend-core/src/index.ts index e45a2a30..2d3d90c0 100644 --- a/src/components/backend-core/src/index.ts +++ b/src/components/backend-core/src/index.ts @@ -744,11 +744,14 @@ async function dispatchTask(req: Request): Promise { 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) diff --git a/src/components/provider-gateway/src/index.ts b/src/components/provider-gateway/src/index.ts index 9948011f..fe8e9207 100644 --- a/src/components/provider-gateway/src/index.ts +++ b/src/components/provider-gateway/src/index.ts @@ -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): Promise { if (!isHostSshConfigured()) { throw new Error(`host SSH bridge is not configured; missing ${missingHostSshFields().join(", ")}`); @@ -629,6 +668,7 @@ async function runHostSsh(payload: Record): Promise): Promise 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 { const workspace = config.upgradeWorkspacePath; - const composeCommand = [ + const composeBaseCommand = [ "docker", "compose", "--env-file", @@ -855,14 +896,39 @@ function upgradePlan(taskId: string): Record { `${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 { 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, 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): Record { + const raw = payload.jsonArrayLimits; + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return {}; + const limits: Record = {}; + 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)[part]; + } + return Array.isArray(current) ? current as JsonValue[] : null; +} + +function applyJsonArrayLimits(bodyText: string, contentType: string, limits: Record): { 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; + const applied: Record = {}; + 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): Promise { + 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 + : {}; + 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 { 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 { 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); diff --git a/src/components/shared/src/index.ts b/src/components/shared/src/index.ts index bc088f5a..54fbcd07 100644 --- a/src/components/shared/src/index.ts +++ b/src/components/shared/src/index.ts @@ -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