update
This commit is contained in:
+11
-1
@@ -7,6 +7,7 @@ import { jobWithTail, listJobs, readJob, runJob } from "./src/jobs";
|
|||||||
import { runChecks } from "./src/check";
|
import { runChecks } from "./src/check";
|
||||||
import { runSsh } from "./src/ssh";
|
import { runSsh } from "./src/ssh";
|
||||||
import { extractRemoteCliOptions, runRemoteCli } from "./src/remote";
|
import { extractRemoteCliOptions, runRemoteCli } from "./src/remote";
|
||||||
|
import { runMicroserviceCommand } from "./src/microservices";
|
||||||
|
|
||||||
const remoteOptions = extractRemoteCliOptions(process.argv.slice(2));
|
const remoteOptions = extractRemoteCliOptions(process.argv.slice(2));
|
||||||
const args = remoteOptions.args;
|
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 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: "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: "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 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: "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 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: "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." },
|
{ 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 (top === "job") {
|
||||||
if (sub === "list") {
|
if (sub === "list") {
|
||||||
emitJson(commandName, { jobs: listJobs() });
|
emitJson(commandName, { jobs: listJobs() });
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { runCommand } from "./command";
|
import { runCommand } from "./command";
|
||||||
import { type UniDeskConfig, repoRoot } from "./config";
|
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 type DebugDispatchCommand = typeof dispatchCommands[number];
|
||||||
|
|
||||||
export function isDebugDispatchCommand(value: unknown): value is DebugDispatchCommand {
|
export function isDebugDispatchCommand(value: unknown): value is DebugDispatchCommand {
|
||||||
|
|||||||
+27
-1
@@ -354,6 +354,28 @@ async function remoteDebugTask(session: FrontendSession, args: string[]): Promis
|
|||||||
return { transport: "frontend", tasksResponse, taskId, task: task ?? null };
|
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> {
|
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");
|
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);
|
const parsed = parseSshArgs(args);
|
||||||
@@ -399,7 +421,7 @@ async function runRemoteCliOverFrontend(options: RemoteCliOptions, config: UniDe
|
|||||||
emitRemoteJson(name, {
|
emitRemoteJson(name, {
|
||||||
transport: "frontend",
|
transport: "frontend",
|
||||||
baseUrl: session.baseUrl,
|
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;
|
return 0;
|
||||||
}
|
}
|
||||||
@@ -415,6 +437,10 @@ async function runRemoteCliOverFrontend(options: RemoteCliOptions, config: UniDe
|
|||||||
emitRemoteJson(name, await remoteDebugTask(session, args));
|
emitRemoteJson(name, await remoteDebugTask(session, args));
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
if (top === "microservice") {
|
||||||
|
emitRemoteJson(name, await remoteMicroservice(session, args));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
if (top === "ssh") {
|
if (top === "ssh") {
|
||||||
return await runRemoteSshOverFrontend(session, sub, args.slice(2));
|
return await runRemoteSshOverFrontend(session, sub, args.slice(2));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -744,11 +744,14 @@ async function dispatchTask(req: Request): Promise<Response> {
|
|||||||
return jsonResponse({ ok: false, error: "providerId is required" }, 400);
|
return jsonResponse({ ok: false, error: "providerId is required" }, 400);
|
||||||
}
|
}
|
||||||
if (command === null) {
|
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"))) {
|
if (command === "host.ssh" && !(await providerSupports(providerId, "host.ssh"))) {
|
||||||
return jsonResponse({ ok: false, error: `provider does not declare host.ssh capability: ${providerId}` }, 409);
|
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)}`;
|
const taskId = `task_${Date.now()}_${Math.random().toString(16).slice(2)}`;
|
||||||
await sql`
|
await sql`
|
||||||
INSERT INTO unidesk_tasks (id, provider_id, command, status, payload, result)
|
INSERT INTO unidesk_tasks (id, provider_id, command, status, payload, result)
|
||||||
|
|||||||
@@ -193,7 +193,7 @@ function sendJson(value: unknown): void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function sendRegister(): 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");
|
if (isHostSshConfigured()) capabilities.push("host.ssh");
|
||||||
sendJson({
|
sendJson({
|
||||||
type: "register",
|
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)\"";
|
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> {
|
async function runHostSsh(payload: Record<string, JsonValue>): Promise<JsonValue> {
|
||||||
if (!isHostSshConfigured()) {
|
if (!isHostSshConfigured()) {
|
||||||
throw new Error(`host SSH bridge is not configured; missing ${missingHostSshFields().join(", ")}`);
|
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`);
|
throw new Error(`host SSH command is too long: ${command.length} bytes`);
|
||||||
}
|
}
|
||||||
const cwd = payloadString(payload, "cwd") ?? config.hostRemoteCwd;
|
const cwd = payloadString(payload, "cwd") ?? config.hostRemoteCwd;
|
||||||
|
if (mode === "exec") assertHostSshCommandAllowed(command, cwd);
|
||||||
const scriptParts = [
|
const scriptParts = [
|
||||||
"set -e",
|
"set -e",
|
||||||
cwd === null ? null : `cd ${shellQuote(cwd)}`,
|
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 {
|
function hostSshRemoteScript(command: string | null, cwd: string | null, cols?: number, rows?: number): string {
|
||||||
const fallbackCwd = config.hostRemoteCwd ?? `/home/${config.hostSshUser ?? "root"}`;
|
const fallbackCwd = config.hostRemoteCwd ?? `/home/${config.hostSshUser ?? "root"}`;
|
||||||
const requestedCwd = cwd ?? fallbackCwd;
|
const requestedCwd = cwd ?? fallbackCwd;
|
||||||
|
if (command !== null && command.length > 0) assertHostSshCommandAllowed(command, requestedCwd);
|
||||||
const loginShell = config.hostLoginShell ?? "/bin/bash";
|
const loginShell = config.hostLoginShell ?? "/bin/bash";
|
||||||
const resize = Number.isFinite(cols) && Number.isFinite(rows)
|
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`
|
? `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> {
|
function upgradePlan(taskId: string): Record<string, JsonValue> {
|
||||||
const workspace = config.upgradeWorkspacePath;
|
const workspace = config.upgradeWorkspacePath;
|
||||||
const composeCommand = [
|
const composeBaseCommand = [
|
||||||
"docker",
|
"docker",
|
||||||
"compose",
|
"compose",
|
||||||
"--env-file",
|
"--env-file",
|
||||||
@@ -855,14 +896,39 @@ function upgradePlan(taskId: string): Record<string, JsonValue> {
|
|||||||
`${workspace}/${config.upgradeComposeFile}`,
|
`${workspace}/${config.upgradeComposeFile}`,
|
||||||
"-p",
|
"-p",
|
||||||
config.upgradeComposeProject,
|
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",
|
"up",
|
||||||
"-d",
|
"-d",
|
||||||
"--no-deps",
|
"--no-deps",
|
||||||
"--build",
|
"--force-recreate",
|
||||||
config.upgradeService,
|
config.upgradeService,
|
||||||
];
|
];
|
||||||
const updaterName = `unidesk-provider-upgrader-${safeDockerName(taskId)}`;
|
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 = [
|
const dockerRunCommand = [
|
||||||
"docker",
|
"docker",
|
||||||
"run",
|
"run",
|
||||||
@@ -894,7 +960,19 @@ function upgradePlan(taskId: string): Record<string, JsonValue> {
|
|||||||
composeFile: config.upgradeComposeFile,
|
composeFile: config.upgradeComposeFile,
|
||||||
envFile: config.upgradeEnvFile,
|
envFile: config.upgradeEnvFile,
|
||||||
},
|
},
|
||||||
composeCommand,
|
composeCommand: composeUpCommand,
|
||||||
|
composeBuildCommand,
|
||||||
|
listServiceContainersCommand,
|
||||||
|
composeUpCommand,
|
||||||
|
replacementStrategy: {
|
||||||
|
buildBeforeRemove: true,
|
||||||
|
removeScope: {
|
||||||
|
projectLabel: config.upgradeComposeProject,
|
||||||
|
serviceLabel: config.upgradeService,
|
||||||
|
},
|
||||||
|
noDeps: true,
|
||||||
|
namedVolumesPreserved: true,
|
||||||
|
},
|
||||||
dockerRunCommand,
|
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> {
|
async function handleDispatch(message: CoreDispatchMessage): Promise<void> {
|
||||||
logger("info", "dispatch_received", { taskId: message.taskId, command: message.command, payload: message.payload });
|
logger("info", "dispatch_received", { taskId: message.taskId, command: message.command, payload: message.payload });
|
||||||
await sendTaskStatus(message.taskId, "accepted", "provider accepted task");
|
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);
|
await sendTaskStatus(message.taskId, "succeeded", "host SSH command completed", result);
|
||||||
return;
|
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 });
|
await sendTaskStatus(message.taskId, "succeeded", "echo completed", { echo: message.payload });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const text = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
|
const text = error instanceof Error ? `${error.name}: ${error.message}` : String(error);
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ export interface ProviderTaskStatusMessage {
|
|||||||
result?: JsonValue;
|
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 {
|
export interface CoreDispatchMessage {
|
||||||
type: "dispatch";
|
type: "dispatch";
|
||||||
@@ -271,7 +271,7 @@ export function parseJsonObject(value: string, name: string): Record<string, Jso
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function isProviderDispatchCommand(value: unknown): value is ProviderDispatchCommand {
|
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 {
|
export function isProviderToCoreMessage(value: unknown): value is ProviderToCoreMessage {
|
||||||
|
|||||||
Reference in New Issue
Block a user