feat: add provider-backed microservices

This commit is contained in:
Codex
2026-05-05 07:56:03 +00:00
parent ef70ca972b
commit abd40fa252
24 changed files with 1656 additions and 51 deletions
+114 -4
View File
@@ -1,9 +1,10 @@
import { mkdirSync, readFileSync } from "node:fs";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { connect } from "node:net";
import { join } from "node:path";
import { chromium } from "playwright";
import { runCommand } from "./command";
import { type UniDeskConfig, repoRoot, rootPath } from "./config";
import { boundedJsonDetail } from "./preview";
type CheckStatus = "passed" | "failed";
@@ -67,7 +68,16 @@ function tcpProbe(host: string, port: number, timeoutMs = 2500): Promise<unknown
}
function addCheck(checks: E2ECheck[], name: string, passed: boolean, detail: unknown): void {
checks.push({ name, status: passed ? "passed" : "failed", detail });
checks.push({
name,
status: passed ? "passed" : "failed",
detail: boundedJsonDetail(detail, passed ? 4_000 : 24_000, {
maxDepth: passed ? 3 : 5,
maxArrayItems: passed ? 3 : 8,
maxObjectKeys: passed ? 12 : 40,
maxStringLength: passed ? 600 : 1600,
}),
});
}
function safeTestId(value: string): string {
@@ -211,9 +221,11 @@ async function exposureChecks(config: UniDeskConfig, urls: PublicUrls, checks: E
const portsText = (portSummary.rows ?? []).map((row) => `${row.name} ${row.ports}`).join("\n");
const corePublic = await fetchProbe(`${urls.blockedCoreUrl}/health`, 2500);
const databasePublic = await tcpProbe(urls.blockedDatabaseHost, urls.blockedDatabasePort);
const findjobPublic = await fetchProbe(`http://${config.network.publicHost}:3254/api/health`, 2500);
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 as { reachable?: boolean }).reachable === false, databasePublic);
addCheck(checks, "network:findjob-public-blocked", (findjobPublic as { reachable?: boolean }).reachable === false, findjobPublic);
}
async function serviceChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2ECheck[]): Promise<void> {
@@ -221,6 +233,14 @@ async function serviceChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2
const coreNodes = dockerCoreJson("/api/nodes");
const systemStatus = dockerCoreJson("/api/nodes/system-status?limit=24");
const dockerStatus = dockerCoreJson("/api/nodes/docker-status");
const microservices = dockerCoreJson("/api/microservices");
const findjobStatus = dockerCoreJson("/api/microservices/findjob/status");
const findjobHealth = dockerCoreJson("/api/microservices/findjob/health");
const findjobSummary = dockerCoreJson("/api/microservices/findjob/proxy/api/summary");
const findjobJobsPreview = dockerCoreJson("/api/microservices/findjob/proxy/api/jobs?__unideskArrayLimit=jobs:5");
const pipelineStatus = dockerCoreJson("/api/microservices/pipeline/status");
const pipelineHealth = dockerCoreJson("/api/microservices/pipeline/health");
const pipelineSnapshot = dockerCoreJson("/api/microservices/pipeline/proxy/api/snapshot?__unideskArrayLimit=registry.components:8,runs:3");
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; labels?: Record<string, unknown> }> } }).body?.nodes ?? [];
@@ -235,6 +255,42 @@ async function serviceChecks(config: UniDeskConfig, urls: PublicUrls, checks: E2
addCheck(checks, "provider:gateway-version-label", mainNode?.labels?.providerGatewayVersion === expectedGatewayVersion && mainNode?.labels?.providerGatewayUpgradePolicy === "always-enabled", { providerId: config.providerGateway.id, expectedGatewayVersion, labels: mainNode?.labels ?? null });
addCheck(checks, "provider:system-status", (systemStatus as { ok?: boolean }).ok === true && mainSystem?.current !== undefined && Number.isFinite(mainSystem.current.cpu?.percent) && Number.isFinite(mainSystem.current.memory?.percent) && mainSystem.current.memory?.mode === "actual_without_cache" && Number.isFinite(mainSystem.current.memory?.cacheBytes) && Number.isFinite(mainSystem.current.disk?.percent) && (mainSystem.history?.length ?? 0) > 0, systemStatusCheckDetail(systemStatus, config.providerGateway.id));
addCheck(checks, "provider:docker-status", (dockerStatus as { ok?: boolean }).ok === true && mainDocker?.dockerStatus !== undefined && ((mainDocker.dockerStatus.counts?.containers ?? 0) > 0 || (mainDocker.dockerStatus.containers?.length ?? 0) > 0), dockerStatusCheckDetail(dockerStatus, config.providerGateway.id));
const microserviceList = (microservices as { body?: { microservices?: Array<{ id?: string; providerId?: string; backend?: { public?: boolean }; runtime?: { providerStatus?: string; container?: { name?: string; state?: string } } }> } }).body?.microservices ?? [];
const findjob = microserviceList.find((service) => service.id === "findjob");
const pipeline = microserviceList.find((service) => service.id === "pipeline");
const findjobSummaryBody = (findjobSummary as { body?: { totalJobs?: number; prioritizedJobs?: number } }).body;
const findjobJobs = (findjobJobsPreview as { body?: { jobs?: unknown[]; _unidesk?: { arrayLimits?: { jobs?: { returnedLength?: number; originalLength?: number } } } } }).body;
const pipelineSnapshotBody = (pipelineSnapshot as { body?: { ok?: boolean; registry?: { ok?: boolean; components?: unknown[] }; pipelines?: unknown[]; runs?: unknown[]; _unidesk?: { arrayLimits?: { "registry.components"?: { returnedLength?: number; originalLength?: number }; runs?: { returnedLength?: number; originalLength?: number } } } } }).body;
const firstPipelineRun = Array.isArray(pipelineSnapshotBody?.runs)
? pipelineSnapshotBody.runs[0] as { runId?: string; pipelineId?: string; status?: string; updatedAt?: string } | undefined
: undefined;
const pipelineSnapshotDetail = {
ok: (pipelineSnapshot as { ok?: boolean }).ok,
status: (pipelineSnapshot as { status?: number }).status,
body: {
ok: pipelineSnapshotBody?.ok,
registryOk: pipelineSnapshotBody?.registry?.ok,
componentPreviewCount: pipelineSnapshotBody?.registry?.components?.length ?? 0,
pipelinePreviewCount: pipelineSnapshotBody?.pipelines?.length ?? 0,
runPreviewCount: pipelineSnapshotBody?.runs?.length ?? 0,
firstRun: firstPipelineRun === undefined ? null : {
runId: firstPipelineRun.runId,
pipelineId: firstPipelineRun.pipelineId,
status: firstPipelineRun.status,
updatedAt: firstPipelineRun.updatedAt,
},
arrayLimits: pipelineSnapshotBody?._unidesk?.arrayLimits,
},
};
addCheck(checks, "microservice:catalog-findjob", (microservices as { ok?: boolean }).ok === true && findjob?.providerId === "D601" && findjob.backend?.public === false, { microservices });
addCheck(checks, "microservice:catalog-pipeline", (microservices as { ok?: boolean }).ok === true && pipeline?.providerId === "D601" && pipeline.backend?.public === false && pipeline.runtime?.container?.name === "pipeline-v2-webui", { microservices });
addCheck(checks, "microservice:findjob-status", (findjobStatus as { ok?: boolean }).ok === true && (findjobStatus as { body?: { microservice?: { id?: string; providerId?: string } } }).body?.microservice?.providerId === "D601", findjobStatus);
addCheck(checks, "microservice:findjob-health", (findjobHealth as { ok?: boolean; body?: { ok?: boolean } }).ok === true && (findjobHealth as { body?: { ok?: boolean } }).body?.ok === true, findjobHealth);
addCheck(checks, "microservice:findjob-summary", (findjobSummary as { ok?: boolean }).ok === true && Number.isFinite(findjobSummaryBody?.totalJobs) && Number.isFinite(findjobSummaryBody?.prioritizedJobs), findjobSummary);
addCheck(checks, "microservice:findjob-jobs-preview", (findjobJobsPreview as { ok?: boolean }).ok === true && Array.isArray(findjobJobs?.jobs) && (findjobJobs.jobs.length ?? 0) > 0 && (findjobJobs._unidesk?.arrayLimits?.jobs?.returnedLength ?? 0) <= 5, findjobJobsPreview);
addCheck(checks, "microservice:pipeline-status", (pipelineStatus as { ok?: boolean }).ok === true && (pipelineStatus as { body?: { microservice?: { id?: string; providerId?: string } } }).body?.microservice?.providerId === "D601", pipelineStatus);
addCheck(checks, "microservice:pipeline-health", (pipelineHealth as { ok?: boolean; body?: { ok?: boolean; service?: string } }).ok === true && (pipelineHealth as { body?: { ok?: boolean } }).body?.ok === true, pipelineHealth);
addCheck(checks, "microservice:pipeline-snapshot", (pipelineSnapshot as { ok?: boolean }).ok === true && pipelineSnapshotBody?.ok === true && pipelineSnapshotBody.registry?.ok === true && Array.isArray(pipelineSnapshotBody.registry.components) && pipelineSnapshotBody.registry.components.length > 0 && Array.isArray(pipelineSnapshotBody.pipelines) && pipelineSnapshotBody.pipelines.length > 0 && Array.isArray(pipelineSnapshotBody.runs) && pipelineSnapshotBody.runs.length > 0 && (pipelineSnapshotBody._unidesk?.arrayLimits?.["registry.components"]?.returnedLength ?? 999) <= 8 && (pipelineSnapshotBody._unidesk?.arrayLimits?.runs?.returnedLength ?? 999) <= 3, pipelineSnapshotDetail);
const upgradeDispatch = dockerCoreJson("/api/dispatch", {
method: "POST",
body: { providerId: config.providerGateway.id, command: "provider.upgrade", payload: { source: "cli-e2e", mode: "plan" } },
@@ -305,7 +361,7 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2
await page.waitForSelector(`text=${config.providerGateway.name}`, { timeout: 10000 });
await page.setViewportSize({ width: 390, height: 860 });
const mobileRailHeights: number[] = [];
for (const moduleLabel of ["运行总览", "资源节点", "任务调度", "系统配置"]) {
for (const moduleLabel of ["运行总览", "资源节点", "任务调度", "微服务", "系统配置"]) {
await page.getByRole("button", { name: new RegExp(moduleLabel) }).click();
await page.waitForTimeout(80);
const height = await page.locator(".rail").evaluate((element) => Math.round(element.getBoundingClientRect().height));
@@ -381,6 +437,41 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2
const gatewayTextLower = gatewayText.toLowerCase();
const sshAvailabilityTexts = await page.locator('[data-testid="gateway-version-page"] [data-testid^="ssh-availability-"]').evaluateAll((elements) => elements.map((element) => (element as HTMLElement).innerText));
const upgradeAvailabilityTexts = await page.locator('[data-testid="gateway-version-page"] [data-testid^="upgrade-availability-"]').evaluateAll((elements) => elements.map((element) => (element as HTMLElement).innerText));
await page.getByRole("button", { name: /微服务/ }).click();
await page.waitForSelector('[data-testid="microservice-catalog-page"]', { timeout: 10000 });
await page.waitForSelector('[data-testid="microservice-row-findjob"]', { timeout: 10000 });
await page.waitForSelector('[data-testid="microservice-row-pipeline"]', { timeout: 10000 });
const microserviceCatalogText = await page.locator('[data-testid="microservice-catalog-page"]').innerText({ timeout: 5000 });
await page.getByRole("button", { name: /FindJob/ }).click();
await page.waitForSelector('[data-testid="findjob-page"]', { timeout: 10000 });
await page.waitForFunction(() => {
const text = document.body.innerText.toLowerCase();
const originalText = document.body.innerText;
return text.includes("findjob 工作台".toLowerCase())
&& text.includes("岗位总量")
&& text.includes("d601")
&& text.includes("近期岗位")
&& /岗位总量\s+\d+/.test(originalText)
&& /health\s+ok/i.test(originalText)
&& /[1-9]\d*\/[1-9]\d*\s+preview/i.test(originalText);
}, undefined, { timeout: 30000 });
const findjobText = await page.locator('[data-testid="findjob-page"]').innerText({ timeout: 5000 });
await page.getByRole("button", { name: /Pipeline/ }).click();
await page.waitForSelector('[data-testid="pipeline-page"]', { timeout: 10000 });
await page.waitForFunction(() => {
const text = document.body.innerText;
const lower = text.toLowerCase();
return lower.includes("pipeline v2 工作台")
&& text.includes("控制图")
&& text.includes("最近运行")
&& /Health\s+OK/i.test(text)
&& /组件\s+\d+/.test(text)
&& /运行记录\s+[1-9]\d*/.test(text);
}, undefined, { timeout: 30000 });
const pipelineText = await page.locator('[data-testid="pipeline-page"]').innerText({ timeout: 5000 });
const microserviceCatalogTextLower = microserviceCatalogText.toLowerCase();
const findjobTextLower = findjobText.toLowerCase();
const pipelineTextLower = pipelineText.toLowerCase();
addCheck(checks, "frontend:login-provider-visible", bodyText.includes(config.providerGateway.id) && bodyText.includes(config.providerGateway.name) && bodyText.includes("核心在线"), { screenshotPath });
addCheck(checks, "frontend:public-provider-info-visible", publicFrontendReached && bodyText.includes(config.providerGateway.id) && bodyText.includes(config.providerGateway.name) && rawText.includes('"status": "online"') && rawText.includes(`"providerId": "${config.providerGateway.id}"`), { frontendUrl: urls.frontendUrl, landedUrl, providerId: config.providerGateway.id, rawTextPreview: rawText.slice(0, 400) });
addCheck(checks, "frontend:mobile-nav-fixed-height", mobileRailMax - mobileRailMin <= 1 && mobileRailMax <= 44, { mobileRailHeights });
@@ -394,6 +485,9 @@ async function frontendCheck(config: UniDeskConfig, urls: PublicUrls, checks: E2
addCheck(checks, "frontend:docker-status-visible", dockerText.toLowerCase().includes("docker desktop 视图") && dockerText.toLowerCase().includes("containers") && dockerText.includes("unidesk_pgdata_10gb") && (dockerText.includes("unidesk-frontend") || dockerText.includes("unidesk-backend-core")), { dockerTextPreview: dockerText.slice(0, 800) });
addCheck(checks, "frontend:gateway-version-records-visible", gatewayTextLower.includes("provider gateway 版本") && gatewayText.includes("自动更新记录") && gatewayText.includes(config.providerGateway.id) && gatewayText.includes(`v${providerGatewayPackageVersion()}`) && gatewayText.includes("provider.upgrade"), { gatewayTextPreview: gatewayText.slice(0, 900) });
addCheck(checks, "frontend:provider-operation-availability-visible", sshAvailabilityTexts.length >= 1 && upgradeAvailabilityTexts.length >= 1 && sshAvailabilityTexts.every((text) => text.includes("SSH 透传")) && upgradeAvailabilityTexts.every((text) => text.includes("远程更新")) && upgradeAvailabilityTexts.some((text) => text.includes("always-enabled")), { sshAvailabilityTexts, upgradeAvailabilityTexts });
addCheck(checks, "frontend:microservice-catalog-visible", microserviceCatalogTextLower.includes("findjob") && microserviceCatalogTextLower.includes("pipeline") && microserviceCatalogText.includes("D601") && microserviceCatalogTextLower.includes("private") && microserviceCatalogText.includes("https://gitee.com/Lyon1998/findjob") && microserviceCatalogText.includes("https://github.com/pikasTech/pipeline"), { microserviceCatalogPreview: microserviceCatalogText.slice(0, 1200) });
addCheck(checks, "frontend:findjob-integrated-visible", findjobTextLower.includes("findjob 工作台".toLowerCase()) && findjobText.includes("岗位总量") && findjobText.includes("D601") && findjobText.includes("近期岗位") && findjobText.includes("仅 UniDesk frontend 代理访问") && /岗位总量\s+\d+/.test(findjobText) && /health\s+ok/i.test(findjobText) && /[1-9]\d*\/[1-9]\d*\s+preview/i.test(findjobText), { findjobTextPreview: findjobText.slice(0, 1200) });
addCheck(checks, "frontend:pipeline-integrated-visible", pipelineTextLower.includes("pipeline v2 工作台".toLowerCase()) && pipelineText.includes("D601") && pipelineText.includes("控制图") && pipelineText.includes("最近运行") && pipelineText.includes("仅 UniDesk frontend 代理访问") && /Health\s+OK/i.test(pipelineText) && /组件\s+\d+/.test(pipelineText) && /运行记录\s+[1-9]\d*/.test(pipelineText), { pipelineTextPreview: pipelineText.slice(0, 1200) });
addCheck(checks, "frontend:no-console-errors", consoleErrors.length === 0, { consoleErrors });
return { screenshotPath, bodyText, consoleErrors };
} finally {
@@ -409,11 +503,27 @@ export async function runE2E(config: UniDeskConfig): Promise<unknown> {
const markerId = databaseChecks(config, checks);
const frontend = await frontendCheck(config, urls, checks);
const ok = checks.every((check) => check.status === "passed");
return {
const fullResult = {
ok,
urls,
markerId,
screenshotPath: frontend.screenshotPath,
checks,
};
const resultPath = rootPath(".state", "e2e", `${markerId}_result.json`);
writeFileSync(resultPath, `${JSON.stringify(fullResult, null, 2)}\n`, "utf8");
return {
ok,
urls,
markerId,
screenshotPath: frontend.screenshotPath,
resultPath,
checkCounts: {
total: checks.length,
passed: checks.filter((check) => check.status === "passed").length,
failed: checks.filter((check) => check.status === "failed").length,
},
checks: checks.map((check) => ({ name: check.name, status: check.status })),
failedChecks: checks.filter((check) => check.status === "failed"),
};
}