feat: secure frontend and provider ingress
This commit is contained in:
@@ -11,7 +11,9 @@ export interface UniDeskConfig {
|
||||
core: { port: number; containerPort: number };
|
||||
frontend: { port: number; containerPort: number };
|
||||
database: { port: number; containerPort: number };
|
||||
providerIngress: { port: number; containerPort: number };
|
||||
};
|
||||
auth: { username: string; password: string; sessionSecret: string; sessionTtlSeconds: number };
|
||||
database: { user: string; password: string; name: string; volume: string; volumeSize: string };
|
||||
providerGateway: {
|
||||
id: string;
|
||||
@@ -67,6 +69,7 @@ export function readConfig(): UniDeskConfig {
|
||||
const runtime = asRecord(parsed.runtime, "runtime");
|
||||
const network = asRecord(parsed.network, "network");
|
||||
const database = asRecord(parsed.database, "database");
|
||||
const auth = asRecord(parsed.auth, "auth");
|
||||
const providerGateway = asRecord(parsed.providerGateway, "providerGateway");
|
||||
const docker = asRecord(parsed.docker, "docker");
|
||||
const paths = asRecord(parsed.paths, "paths");
|
||||
@@ -83,6 +86,7 @@ export function readConfig(): UniDeskConfig {
|
||||
core: portPair(network, "core"),
|
||||
frontend: portPair(network, "frontend"),
|
||||
database: portPair(network, "database"),
|
||||
providerIngress: portPair(network, "providerIngress"),
|
||||
},
|
||||
database: {
|
||||
user: stringField(database, "user", "database"),
|
||||
@@ -113,5 +117,11 @@ export function readConfig(): UniDeskConfig {
|
||||
port: numberField(sshForwarding, "port", "sshForwarding"),
|
||||
user: stringField(sshForwarding, "user", "sshForwarding"),
|
||||
},
|
||||
auth: {
|
||||
username: stringField(auth, "username", "auth"),
|
||||
password: stringField(auth, "password", "auth"),
|
||||
sessionSecret: stringField(auth, "sessionSecret", "auth"),
|
||||
sessionTtlSeconds: numberField(auth, "sessionTtlSeconds", "auth"),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
+38
-8
@@ -1,4 +1,5 @@
|
||||
import { type UniDeskConfig } from "./config";
|
||||
import { runCommand } from "./command";
|
||||
import { type UniDeskConfig, repoRoot } from "./config";
|
||||
|
||||
async function readJson(url: string, init?: RequestInit): Promise<unknown> {
|
||||
const controller = new AbortController();
|
||||
@@ -7,24 +8,53 @@ async function readJson(url: string, init?: RequestInit): Promise<unknown> {
|
||||
const res = await fetch(url, { ...init, signal: controller.signal });
|
||||
const text = await res.text();
|
||||
return { ok: res.ok, status: res.status, body: text.length > 0 ? JSON.parse(text) : null };
|
||||
} catch (error) {
|
||||
return { ok: false, error: error instanceof Error ? error.message : String(error) };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function coreInternalFetch(path: string, init?: { method?: string; body?: unknown }): unknown {
|
||||
const code = `
|
||||
const res = await fetch(${JSON.stringify(`http://127.0.0.1:8080${path}`)}, ${JSON.stringify({
|
||||
method: init?.method ?? "GET",
|
||||
headers: init?.body === undefined ? undefined : { "content-type": "application/json" },
|
||||
body: init?.body === undefined ? undefined : JSON.stringify(init.body),
|
||||
})});
|
||||
const text = await res.text();
|
||||
let body = null;
|
||||
try { body = text ? JSON.parse(text) : null; } catch { body = { text }; }
|
||||
console.log(JSON.stringify({ ok: res.ok, status: res.status, body }));
|
||||
`;
|
||||
const result = runCommand(["docker", "exec", "unidesk-backend-core", "bun", "-e", code], repoRoot);
|
||||
if (result.exitCode !== 0) {
|
||||
return { ok: false, exitCode: result.exitCode, stdoutTail: result.stdout.slice(-1200), stderrTail: result.stderr.slice(-1200) };
|
||||
}
|
||||
try {
|
||||
return JSON.parse(result.stdout.trim()) as unknown;
|
||||
} catch {
|
||||
return { ok: true, stdoutTail: result.stdout.slice(-1200), stderrTail: result.stderr.slice(-1200) };
|
||||
}
|
||||
}
|
||||
|
||||
export async function debugHealth(config: UniDeskConfig): Promise<unknown> {
|
||||
return {
|
||||
core: await readJson(`http://127.0.0.1:${config.network.core.port}/health`),
|
||||
overview: await readJson(`http://127.0.0.1:${config.network.core.port}/api/overview`),
|
||||
nodes: await readJson(`http://127.0.0.1:${config.network.core.port}/api/nodes`),
|
||||
frontend: await readJson(`http://127.0.0.1:${config.network.frontend.port}/health`),
|
||||
coreInternal: await coreInternalFetch("/health"),
|
||||
overviewInternal: await coreInternalFetch("/api/overview"),
|
||||
nodesInternal: await coreInternalFetch("/api/nodes"),
|
||||
frontendPublic: await readJson(`http://127.0.0.1:${config.network.frontend.port}/health`),
|
||||
providerIngressPublic: await readJson(`http://127.0.0.1:${config.network.providerIngress.port}/health`),
|
||||
publicExposureBoundary: {
|
||||
coreHostPort: { port: config.network.core.port, expected: "not-exposed" },
|
||||
databaseHostPort: { port: config.network.database.port, expected: "not-exposed" },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function debugDispatch(config: UniDeskConfig, providerId: string, command: "docker.ps" | "echo"): Promise<unknown> {
|
||||
return readJson(`http://127.0.0.1:${config.network.core.port}/api/dispatch`, {
|
||||
return coreInternalFetch("/api/dispatch", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ providerId, command, payload: { source: "cli-debug" } }),
|
||||
body: { providerId, command, payload: { source: "cli-debug" } },
|
||||
});
|
||||
}
|
||||
|
||||
+51
-14
@@ -48,14 +48,17 @@ export function writeComposeEnv(config: UniDeskConfig, freshLogPrefix: boolean):
|
||||
const stateDir = rootPath(config.paths.stateDir);
|
||||
mkdirSync(stateDir, { recursive: true });
|
||||
const envFile = join(stateDir, "docker-compose.env");
|
||||
let logDir: string;
|
||||
let logPrefix: string;
|
||||
if (!freshLogPrefix && existsSync(envFile)) {
|
||||
const raw = readFileSync(envFile, "utf8");
|
||||
const logDir = raw.match(/^UNIDESK_LOG_DIR=(.*)$/m)?.[1]?.replace(/^"|"$/g, "") ?? rootPath(config.paths.logsDir);
|
||||
const logPrefix = raw.match(/^UNIDESK_LOG_PREFIX=(.*)$/m)?.[1]?.replace(/^"|"$/g, "") ?? localDateParts(new Date()).stamp;
|
||||
return { envFile, logDir, logPrefix };
|
||||
logDir = raw.match(/^UNIDESK_LOG_DIR=(.*)$/m)?.[1]?.replace(/^"|"$/g, "") ?? rootPath(config.paths.logsDir);
|
||||
logPrefix = raw.match(/^UNIDESK_LOG_PREFIX=(.*)$/m)?.[1]?.replace(/^"|"$/g, "") ?? localDateParts(new Date()).stamp;
|
||||
} else {
|
||||
const parts = localDateParts(new Date());
|
||||
logDir = resolve(rootPath(config.paths.logsDir, parts.day));
|
||||
logPrefix = parts.stamp;
|
||||
}
|
||||
const parts = localDateParts(new Date());
|
||||
const logDir = resolve(rootPath(config.paths.logsDir, parts.day));
|
||||
mkdirSync(logDir, { recursive: true });
|
||||
chmodSync(logDir, 0o777);
|
||||
const labels = JSON.stringify(config.providerGateway.labels);
|
||||
@@ -64,6 +67,7 @@ export function writeComposeEnv(config: UniDeskConfig, freshLogPrefix: boolean):
|
||||
UNIDESK_CORE_PORT: String(config.network.core.port),
|
||||
UNIDESK_FRONTEND_PORT: String(config.network.frontend.port),
|
||||
UNIDESK_DATABASE_PORT: String(config.network.database.port),
|
||||
UNIDESK_PROVIDER_INGRESS_PORT: String(config.network.providerIngress.port),
|
||||
UNIDESK_DATABASE_USER: config.database.user,
|
||||
UNIDESK_DATABASE_PASSWORD: config.database.password,
|
||||
UNIDESK_DATABASE_NAME: config.database.name,
|
||||
@@ -71,18 +75,22 @@ export function writeComposeEnv(config: UniDeskConfig, freshLogPrefix: boolean):
|
||||
UNIDESK_PROVIDER_ID: config.providerGateway.id,
|
||||
UNIDESK_PROVIDER_NAME: config.providerGateway.name,
|
||||
UNIDESK_PROVIDER_LABELS_JSON: labels,
|
||||
UNIDESK_AUTH_USERNAME: config.auth.username,
|
||||
UNIDESK_AUTH_PASSWORD: config.auth.password,
|
||||
UNIDESK_SESSION_SECRET: config.auth.sessionSecret,
|
||||
UNIDESK_SESSION_TTL_SECONDS: String(config.auth.sessionTtlSeconds),
|
||||
UNIDESK_HEARTBEAT_INTERVAL_MS: String(config.providerGateway.heartbeatIntervalMs),
|
||||
UNIDESK_HEARTBEAT_TIMEOUT_MS: "90000",
|
||||
UNIDESK_RECONNECT_BASE_MS: String(config.providerGateway.reconnectBaseMs),
|
||||
UNIDESK_RECONNECT_MAX_MS: String(config.providerGateway.reconnectMaxMs),
|
||||
UNIDESK_LOG_DIR: logDir,
|
||||
UNIDESK_LOG_PREFIX: parts.stamp,
|
||||
UNIDESK_LOG_PREFIX: logPrefix,
|
||||
UNIDESK_HOST_SSH_HOST: config.sshForwarding.host,
|
||||
UNIDESK_HOST_SSH_PORT: String(config.sshForwarding.port),
|
||||
UNIDESK_HOST_SSH_USER: config.sshForwarding.user,
|
||||
};
|
||||
writeFileSync(envFile, Object.entries(lines).map(([key, value]) => `${key}=${envValue(value)}`).join("\n") + "\n", "utf8");
|
||||
return { envFile, logDir, logPrefix: parts.stamp };
|
||||
return { envFile, logDir, logPrefix };
|
||||
}
|
||||
|
||||
export function composeConfig(config: UniDeskConfig): { runtimeEnv: ComposeRuntimeEnv; command: string[]; result: ReturnType<typeof runCommand> } {
|
||||
@@ -117,9 +125,8 @@ export function stopStack(config: UniDeskConfig): unknown {
|
||||
|
||||
function fixedPorts(config: UniDeskConfig): Array<{ name: string; port: number; listening: boolean }> {
|
||||
return [
|
||||
{ name: "backend-core", port: config.network.core.port, listening: isPortListening(config.network.core.port) },
|
||||
{ name: "frontend", port: config.network.frontend.port, listening: isPortListening(config.network.frontend.port) },
|
||||
{ name: "database", port: config.network.database.port, listening: isPortListening(config.network.database.port) },
|
||||
{ name: "provider-ingress", port: config.network.providerIngress.port, listening: isPortListening(config.network.providerIngress.port) },
|
||||
];
|
||||
}
|
||||
|
||||
@@ -166,21 +173,51 @@ async function probe(url: string): Promise<unknown> {
|
||||
}
|
||||
}
|
||||
|
||||
function dockerExecJson(container: string, code: string): unknown {
|
||||
const result = runCommand(["docker", "exec", container, "bun", "-e", code], repoRoot);
|
||||
if (result.exitCode !== 0) {
|
||||
return { ok: false, exitCode: result.exitCode, stdout: result.stdout.slice(-1200), stderr: result.stderr.slice(-1200) };
|
||||
}
|
||||
try {
|
||||
return JSON.parse(result.stdout.trim()) as unknown;
|
||||
} catch {
|
||||
return { ok: true, stdout: result.stdout.slice(-1200), stderr: result.stderr.slice(-1200) };
|
||||
}
|
||||
}
|
||||
|
||||
function dockerExec(config: UniDeskConfig, container: string, command: string[]): unknown {
|
||||
const result = runCommand(["docker", "exec", container, ...command], repoRoot);
|
||||
return { ok: result.exitCode === 0, exitCode: result.exitCode, stdout: result.stdout.slice(-1200), stderr: result.stderr.slice(-1200) };
|
||||
}
|
||||
|
||||
export async function stackStatus(config: UniDeskConfig): Promise<unknown> {
|
||||
const runtimeEnv = writeComposeEnv(config, false);
|
||||
const coreHealth = dockerExecJson("unidesk-backend-core", "fetch('http://127.0.0.1:8080/health').then(r=>r.json()).then(j=>console.log(JSON.stringify({ok:true,status:200,body:j}))).catch(e=>{console.log(JSON.stringify({ok:false,error:String(e)}));process.exit(1)})");
|
||||
const overview = dockerExecJson("unidesk-backend-core", "fetch('http://127.0.0.1:8080/api/overview').then(r=>r.json()).then(j=>console.log(JSON.stringify({ok:true,status:200,body:j}))).catch(e=>{console.log(JSON.stringify({ok:false,error:String(e)}));process.exit(1)})");
|
||||
return {
|
||||
runtimeEnv,
|
||||
ports: fixedPorts(config),
|
||||
publicPorts: fixedPorts(config),
|
||||
blockedPublicPorts: [
|
||||
{ name: "backend-core-rest", port: config.network.core.port, listening: isPortListening(config.network.core.port), expected: "not-listening" },
|
||||
{ name: "database", port: config.network.database.port, listening: isPortListening(config.network.database.port), expected: "not-listening" },
|
||||
],
|
||||
internalPorts: [
|
||||
{ name: "backend-core", containerPort: config.network.core.containerPort, hostPort: null },
|
||||
{ name: "database", containerPort: config.network.database.containerPort, hostPort: null },
|
||||
],
|
||||
containers: dockerContainers(config),
|
||||
health: {
|
||||
core: await probe(`http://127.0.0.1:${config.network.core.port}/health`),
|
||||
core: coreHealth,
|
||||
frontend: await probe(`http://127.0.0.1:${config.network.frontend.port}/health`),
|
||||
overview: await probe(`http://127.0.0.1:${config.network.core.port}/api/overview`),
|
||||
providerIngress: await probe(`http://127.0.0.1:${config.network.providerIngress.port}/health`),
|
||||
database: dockerExec(config, "unidesk-database", ["pg_isready", "-U", config.database.user, "-d", config.database.name]),
|
||||
overview,
|
||||
},
|
||||
urls: {
|
||||
core: `http://${config.network.publicHost}:${config.network.core.port}`,
|
||||
frontend: `http://${config.network.publicHost}:${config.network.frontend.port}`,
|
||||
database: `postgres://${config.database.user}:***@${config.network.publicHost}:${config.network.database.port}/${config.database.name}`,
|
||||
providerIngress: `ws://${config.network.publicHost}:${config.network.providerIngress.port}/ws/provider`,
|
||||
internalCore: `http://backend-core:${config.network.core.containerPort}`,
|
||||
internalDatabase: `postgres://${config.database.user}:***@database:${config.network.database.containerPort}/${config.database.name}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
+108
-42
@@ -14,27 +14,35 @@ interface E2ECheck {
|
||||
|
||||
interface PublicUrls {
|
||||
frontendUrl: string;
|
||||
coreUrl: string;
|
||||
databaseHost: string;
|
||||
databasePort: number;
|
||||
providerIngressHealthUrl: string;
|
||||
providerIngressWsUrl: string;
|
||||
blockedCoreUrl: string;
|
||||
blockedDatabaseHost: string;
|
||||
blockedDatabasePort: number;
|
||||
}
|
||||
|
||||
function publicUrls(config: UniDeskConfig): PublicUrls {
|
||||
return {
|
||||
frontendUrl: `http://${config.network.publicHost}:${config.network.frontend.port}`,
|
||||
coreUrl: `http://${config.network.publicHost}:${config.network.core.port}`,
|
||||
databaseHost: config.network.publicHost,
|
||||
databasePort: config.network.database.port,
|
||||
providerIngressHealthUrl: `http://${config.network.publicHost}:${config.network.providerIngress.port}/health`,
|
||||
providerIngressWsUrl: `ws://${config.network.publicHost}:${config.network.providerIngress.port}/ws/provider`,
|
||||
blockedCoreUrl: `http://${config.network.publicHost}:${config.network.core.port}`,
|
||||
blockedDatabaseHost: config.network.publicHost,
|
||||
blockedDatabasePort: config.network.database.port,
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchJson(url: string): Promise<unknown> {
|
||||
async function fetchProbe(url: string, timeoutMs = 8000): Promise<unknown> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 8000);
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await fetch(url, { signal: controller.signal });
|
||||
const text = await response.text();
|
||||
return { ok: response.ok, status: response.status, body: text.length > 0 ? JSON.parse(text) : null };
|
||||
let body: unknown = text;
|
||||
try { body = text.length > 0 ? JSON.parse(text) : null; } catch { /* keep text body */ }
|
||||
return { reachable: true, ok: response.ok, status: response.status, body };
|
||||
} catch (error) {
|
||||
return { reachable: false, ok: false, error: error instanceof Error ? error.message : String(error) };
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
@@ -63,15 +71,81 @@ function runPsql(config: UniDeskConfig, sql: string): { ok: boolean; stdout: str
|
||||
return { ok: result.exitCode === 0, stdout: result.stdout.trim(), stderr: result.stderr.trim(), exitCode: result.exitCode };
|
||||
}
|
||||
|
||||
async function apiChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2ECheck[]): Promise<void> {
|
||||
const overview = await fetchJson(`${urls.coreUrl}/api/overview`);
|
||||
const nodes = await fetchJson(`${urls.coreUrl}/api/nodes`);
|
||||
addCheck(checks, "core:public-overview", (overview as { ok?: boolean; body?: { ok?: boolean; onlineNodeCount?: number } }).ok === true && (overview as { body?: { ok?: boolean; onlineNodeCount?: number } }).body?.ok === true && ((overview as { body?: { onlineNodeCount?: number } }).body?.onlineNodeCount ?? 0) >= 1, overview);
|
||||
const nodeList = (nodes as { body?: { nodes?: Array<{ providerId?: string; status?: string }> } }).body?.nodes ?? [];
|
||||
addCheck(checks, "core:public-nodes", nodeList.some((node) => node.providerId === config.providerGateway.id && node.status === "online"), nodes);
|
||||
function dockerCoreJson(path: string): unknown {
|
||||
const code = `
|
||||
const res = await fetch(${JSON.stringify(`http://127.0.0.1:8080${path}`)});
|
||||
const text = await res.text();
|
||||
let body = null;
|
||||
try { body = text ? JSON.parse(text) : null; } catch { body = { text }; }
|
||||
console.log(JSON.stringify({ ok: res.ok, status: res.status, body }));
|
||||
`;
|
||||
const result = runCommand(["docker", "exec", "unidesk-backend-core", "bun", "-e", code], repoRoot);
|
||||
if (result.exitCode !== 0) return { ok: false, exitCode: result.exitCode, stdout: result.stdout.slice(-1200), stderr: result.stderr.slice(-1200) };
|
||||
try {
|
||||
return JSON.parse(result.stdout.trim()) as unknown;
|
||||
} catch {
|
||||
return { ok: true, stdout: result.stdout.slice(-1200), stderr: result.stderr.slice(-1200) };
|
||||
}
|
||||
}
|
||||
|
||||
function databaseChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2ECheck[]): string {
|
||||
function dockerPortSummary(): unknown {
|
||||
const result = runCommand([
|
||||
"docker",
|
||||
"ps",
|
||||
"--filter",
|
||||
"label=com.docker.compose.project=unidesk",
|
||||
"--format",
|
||||
"{{.Names}}\t{{.Ports}}",
|
||||
], repoRoot);
|
||||
return {
|
||||
ok: result.exitCode === 0,
|
||||
exitCode: result.exitCode,
|
||||
rows: result.stdout.trim().split("\n").filter(Boolean).map((line) => {
|
||||
const [name, ports = ""] = line.split("\t");
|
||||
return { name, ports };
|
||||
}),
|
||||
stderr: result.stderr.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
async function exposureChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2ECheck[]): Promise<void> {
|
||||
const portSummary = dockerPortSummary() as { rows?: Array<{ name: string; ports: string }> };
|
||||
const portsText = (portSummary.rows ?? []).map((row) => `${row.name} ${row.ports}`).join("\n");
|
||||
const corePublic = await fetchProbe(`${urls.blockedCoreUrl}/health`, 2500);
|
||||
const databasePublic = runCommand([
|
||||
"docker",
|
||||
"run",
|
||||
"--rm",
|
||||
"postgres:16-alpine",
|
||||
"pg_isready",
|
||||
"-h",
|
||||
urls.blockedDatabaseHost,
|
||||
"-p",
|
||||
String(urls.blockedDatabasePort),
|
||||
"-U",
|
||||
config.database.user,
|
||||
], repoRoot);
|
||||
addCheck(checks, "network:only-frontend-provider-ports", !portsText.includes(`:${config.network.core.port}->`) && !portsText.includes(`:${config.network.database.port}->`), portSummary);
|
||||
addCheck(checks, "network:core-public-blocked", (corePublic as { reachable?: boolean }).reachable === false, corePublic);
|
||||
addCheck(checks, "network:database-public-blocked", databasePublic.exitCode !== 0, {
|
||||
exitCode: databasePublic.exitCode,
|
||||
stdout: databasePublic.stdout.trim(),
|
||||
stderr: databasePublic.stderr.trim(),
|
||||
});
|
||||
}
|
||||
|
||||
async function serviceChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2ECheck[]): Promise<void> {
|
||||
const coreOverview = dockerCoreJson("/api/overview");
|
||||
const coreNodes = dockerCoreJson("/api/nodes");
|
||||
const providerIngress = await fetchProbe(urls.providerIngressHealthUrl);
|
||||
const overviewBody = (coreOverview as { body?: { ok?: boolean; dbReady?: boolean; onlineNodeCount?: number } }).body;
|
||||
const nodeList = (coreNodes as { body?: { nodes?: Array<{ providerId?: string; status?: string }> } }).body?.nodes ?? [];
|
||||
addCheck(checks, "core:internal-overview", (coreOverview as { ok?: boolean }).ok === true && overviewBody?.ok === true && overviewBody.dbReady === true && (overviewBody.onlineNodeCount ?? 0) >= 1, coreOverview);
|
||||
addCheck(checks, "provider:self-node-online", nodeList.some((node) => node.providerId === config.providerGateway.id && node.status === "online"), coreNodes);
|
||||
addCheck(checks, "provider-ingress:public-health", (providerIngress as { ok?: boolean; body?: { ok?: boolean } }).ok === true && (providerIngress as { body?: { ok?: boolean } }).body?.ok === true, providerIngress);
|
||||
}
|
||||
|
||||
function databaseChecks(config: UniDeskConfig, checks: E2ECheck[]): string {
|
||||
const markerId = `e2e_${Date.now()}_${Math.random().toString(16).slice(2, 8)}`;
|
||||
const markerSql = `
|
||||
CREATE TABLE IF NOT EXISTS unidesk_e2e_markers (
|
||||
@@ -87,25 +161,6 @@ function databaseChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2EChec
|
||||
const marker = runPsql(config, markerSql);
|
||||
addCheck(checks, "database:named-volume-write", marker.ok && marker.stdout.includes(`marker=${markerId}`), marker);
|
||||
addCheck(checks, "database:provider-state", marker.ok && marker.stdout.includes("online_main_server=1"), marker);
|
||||
|
||||
const publicProbe = runCommand([
|
||||
"docker",
|
||||
"run",
|
||||
"--rm",
|
||||
"postgres:16-alpine",
|
||||
"pg_isready",
|
||||
"-h",
|
||||
urls.databaseHost,
|
||||
"-p",
|
||||
String(urls.databasePort),
|
||||
"-U",
|
||||
config.database.user,
|
||||
], repoRoot);
|
||||
addCheck(checks, "database:public-port", publicProbe.exitCode === 0, {
|
||||
exitCode: publicProbe.exitCode,
|
||||
stdout: publicProbe.stdout.trim(),
|
||||
stderr: publicProbe.stderr.trim(),
|
||||
});
|
||||
return markerId;
|
||||
}
|
||||
|
||||
@@ -122,14 +177,24 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2
|
||||
});
|
||||
page.on("pageerror", (error) => consoleErrors.push(error.message));
|
||||
await page.goto(urls.frontendUrl, { waitUntil: "domcontentloaded", timeout: 15000 });
|
||||
await page.waitForFunction(() => document.querySelector("#conn-text")?.textContent?.includes("核心在线"), undefined, { timeout: 15000 });
|
||||
await page.waitForSelector('[data-testid="login-screen"]', { timeout: 10000 });
|
||||
await page.fill('input[name="username"]', config.auth.username);
|
||||
await page.fill('input[name="password"]', config.auth.password);
|
||||
await page.click('button[type="submit"]');
|
||||
await page.waitForSelector('[data-testid="app-shell"]', { timeout: 10000 });
|
||||
await page.waitForFunction(() => document.querySelector('[data-testid="conn-text"]')?.textContent?.includes("核心在线"), undefined, { timeout: 15000 });
|
||||
await page.waitForSelector(`text=${config.providerGateway.id}`, { timeout: 10000 });
|
||||
await page.waitForSelector("text=Online Nodes", { timeout: 5000 });
|
||||
await page.waitForSelector(`text=${config.providerGateway.name}`, { timeout: 10000 });
|
||||
const bodyText = await page.locator("body").innerText({ timeout: 5000 });
|
||||
const nodeCount = await page.locator("#node-count").innerText({ timeout: 5000 });
|
||||
const metricText = await page.locator("#metric-grid").innerText({ timeout: 5000 });
|
||||
const rawBlocksBefore = await page.locator("pre.raw-json").count();
|
||||
const nakedJsonText = bodyText.includes('{"') || bodyText.includes('"providerId"') || bodyText.includes('"labels"');
|
||||
await page.screenshot({ path: screenshotPath, fullPage: true });
|
||||
addCheck(checks, "frontend:public-page-provider-visible", bodyText.includes(config.providerGateway.id) && bodyText.includes(config.providerGateway.name), { nodeCount, metricText, screenshotPath });
|
||||
await page.getByTestId(`raw-node-${config.providerGateway.id.replace(/[^a-zA-Z0-9_-]/g, "_")}`).click();
|
||||
await page.waitForSelector('[data-testid="raw-json"]', { timeout: 5000 });
|
||||
const rawText = await page.locator('[data-testid="raw-json"]').innerText({ timeout: 5000 });
|
||||
addCheck(checks, "frontend:login-provider-visible", bodyText.includes(config.providerGateway.id) && bodyText.includes(config.providerGateway.name) && bodyText.includes("核心在线"), { screenshotPath });
|
||||
addCheck(checks, "frontend:no-naked-json-before-click", rawBlocksBefore === 0 && !nakedJsonText, { rawBlocksBefore, nakedJsonText });
|
||||
addCheck(checks, "frontend:raw-json-explicit-button", rawText.includes('"providerId"') && rawText.includes(config.providerGateway.id), { rawTextPreview: rawText.slice(0, 400) });
|
||||
addCheck(checks, "frontend:no-console-errors", consoleErrors.length === 0, { consoleErrors });
|
||||
return { screenshotPath, bodyText, consoleErrors };
|
||||
} finally {
|
||||
@@ -140,8 +205,9 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2
|
||||
export async function runE2E(config: UniDeskConfig): Promise<unknown> {
|
||||
const checks: E2ECheck[] = [];
|
||||
const urls = publicUrls(config);
|
||||
await apiChecks(config, urls, checks);
|
||||
const markerId = databaseChecks(config, urls, checks);
|
||||
await exposureChecks(config, urls, checks);
|
||||
await serviceChecks(config, urls, checks);
|
||||
const markerId = databaseChecks(config, checks);
|
||||
const frontend = await frontendCheck(config, urls, checks);
|
||||
const ok = checks.every((check) => check.status === "passed");
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user