fix: 恢复 HWPOD 节点接入产物路由
This commit is contained in:
@@ -10,7 +10,7 @@ import { createHwpodNodeWsRegistry } from "../../internal/cloud/hwpod-node-ws-re
|
|||||||
|
|
||||||
const runtime = hwpodRuntime();
|
const runtime = hwpodRuntime();
|
||||||
const nodeRegistry = createHwpodNodeWsRegistry({ env: process.env });
|
const nodeRegistry = createHwpodNodeWsRegistry({ env: process.env });
|
||||||
const app = createHwpodHttpApp({ nodeOpsApiUrl: runtime.nodeOpsApiUrl, temporal: runtime.temporal, specRegistry: runtime.specRegistry, nodeRegistry });
|
const app = createHwpodHttpApp({ nodeOpsApiUrl: runtime.nodeOpsApiUrl, temporal: runtime.temporal, specRegistry: runtime.specRegistry, nodeRegistry, env: process.env });
|
||||||
const host = process.env.HWPOD_API_HOST || "0.0.0.0";
|
const host = process.env.HWPOD_API_HOST || "0.0.0.0";
|
||||||
const port = positivePort(process.env.HWPOD_API_PORT, 6681);
|
const port = positivePort(process.env.HWPOD_API_PORT, 6681);
|
||||||
const expectedNodeToken = required(process.env.HWPOD_NODE_WS_TOKEN, "HWPOD_NODE_WS_TOKEN");
|
const expectedNodeToken = required(process.env.HWPOD_NODE_WS_TOKEN, "HWPOD_NODE_WS_TOKEN");
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { createCloudApiBunServer } from "./bun-server.ts";
|
|||||||
import { createHwpodNodeWsRegistry } from "./hwpod-node-ws-registry.ts";
|
import { createHwpodNodeWsRegistry } from "./hwpod-node-ws-registry.ts";
|
||||||
import { createMemoryHwpodOperationLedger } from "./hwpod-operation-ledger.ts";
|
import { createMemoryHwpodOperationLedger } from "./hwpod-operation-ledger.ts";
|
||||||
import { createCloudApiServer } from "./server.ts";
|
import { createCloudApiServer } from "./server.ts";
|
||||||
|
import { hwlabNodeBundledArtifact } from "../hwpod/node-artifact.ts";
|
||||||
import { connectHwpodNodeWs, createHwpodNodeServer } from "../../tools/src/hwpod-node-lib.ts";
|
import { connectHwpodNodeWs, createHwpodNodeServer } from "../../tools/src/hwpod-node-lib.ts";
|
||||||
|
|
||||||
function createHwpodTestCloudApiServer(options: any = {}) {
|
function createHwpodTestCloudApiServer(options: any = {}) {
|
||||||
@@ -92,6 +93,7 @@ test("cloud-api exposes hwlab-node Python update metadata", async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test("cloud-api serves bundled hwlab-node Python updater artifact by default", async () => {
|
test("cloud-api serves bundled hwlab-node Python updater artifact by default", async () => {
|
||||||
|
const bundled = hwlabNodeBundledArtifact();
|
||||||
const server = createHwpodTestCloudApiServer({ env: { PATH: process.env.PATH, HWLAB_PUBLIC_ENDPOINT: "https://hwlab.pikapython.com" } });
|
const server = createHwpodTestCloudApiServer({ env: { PATH: process.env.PATH, HWLAB_PUBLIC_ENDPOINT: "https://hwlab.pikapython.com" } });
|
||||||
await listen(server);
|
await listen(server);
|
||||||
try {
|
try {
|
||||||
@@ -99,18 +101,18 @@ test("cloud-api serves bundled hwlab-node Python updater artifact by default", a
|
|||||||
const payload = await response.json();
|
const payload = await response.json();
|
||||||
assert.equal(response.status, 200);
|
assert.equal(response.status, 200);
|
||||||
assert.equal(payload.updateAvailable, true);
|
assert.equal(payload.updateAvailable, true);
|
||||||
assert.equal(payload.latestVersion, "0.1.2");
|
assert.equal(payload.latestVersion, bundled.version);
|
||||||
assert.equal(payload.downloadUrl, "https://hwlab.pikapython.com/v1/hwlab-node/download/hwlab-node.py");
|
assert.equal(payload.downloadUrl, "https://hwlab.pikapython.com/v1/hwlab-node/download/hwlab-node.py");
|
||||||
assert.match(payload.sha256, /^[a-f0-9]{64}$/u);
|
assert.match(payload.sha256, /^[a-f0-9]{64}$/u);
|
||||||
assert.equal(payload.artifact.version, "0.1.2");
|
assert.equal(payload.artifact.version, bundled.version);
|
||||||
assert.equal(payload.artifact.fileName, "hwlab-node.py");
|
assert.equal(payload.artifact.fileName, "hwlab-node.py");
|
||||||
assert.ok(payload.artifact.sizeBytes > 0);
|
assert.ok(payload.artifact.sizeBytes > 0);
|
||||||
|
|
||||||
const download = await fetch(`${serverUrl(server)}/v1/hwlab-node/download/hwlab-node.py`);
|
const download = await fetch(`${serverUrl(server)}/v1/hwlab-node/download/hwlab-node.py`);
|
||||||
const text = await download.text();
|
const text = await download.text();
|
||||||
assert.equal(download.status, 200);
|
assert.equal(download.status, 200);
|
||||||
assert.equal(download.headers.get("x-hwlab-node-version"), "0.1.2");
|
assert.equal(download.headers.get("x-hwlab-node-version"), bundled.version);
|
||||||
assert.ok(text.includes('APP_VERSION = "0.1.2"'));
|
assert.equal(text, bundled.content);
|
||||||
} finally {
|
} finally {
|
||||||
await close(server);
|
await close(server);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,7 @@
|
|||||||
* Implementation reference: draft-2026-07-13-p0-cloud-console。
|
* Implementation reference: draft-2026-07-13-p0-cloud-console。
|
||||||
* Responsibility: 提供 HWLAB Node 更新、HWPOD typed topology、显式有界 readiness 探测与 node-ops 转发。
|
* Responsibility: 提供 HWLAB Node 更新、HWPOD typed topology、显式有界 readiness 探测与 node-ops 转发。
|
||||||
*/
|
*/
|
||||||
import { createHash, randomUUID } from "node:crypto";
|
import { randomUUID } from "node:crypto";
|
||||||
import { readFileSync } from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
import { ENVIRONMENT_DEV } from "../protocol/index.mjs";
|
import { ENVIRONMENT_DEV } from "../protocol/index.mjs";
|
||||||
import { CLOUD_API_SERVICE_ID } from "../audit/index.mjs";
|
import { CLOUD_API_SERVICE_ID } from "../audit/index.mjs";
|
||||||
@@ -14,10 +12,8 @@ import {
|
|||||||
hwpodSpecDiscoveryPayload,
|
hwpodSpecDiscoveryPayload,
|
||||||
hwpodSpecWorkspaceProbePlan
|
hwpodSpecWorkspaceProbePlan
|
||||||
} from "./hwpod-spec-discovery.ts";
|
} from "./hwpod-spec-discovery.ts";
|
||||||
import {
|
import { buildHwpodTopologyReadModel } from "./hwpod-topology-read-model.ts";
|
||||||
buildHwpodTopologyReadModel,
|
import { buildHwlabNodeUpdatePayload, hwlabNodeBundledArtifact } from "../hwpod/node-artifact.ts";
|
||||||
HWPOD_NODE_READINESS_DEFINITIONS
|
|
||||||
} from "./hwpod-topology-read-model.ts";
|
|
||||||
import {
|
import {
|
||||||
getHeader,
|
getHeader,
|
||||||
parsePositiveInteger,
|
parsePositiveInteger,
|
||||||
@@ -92,52 +88,7 @@ export function handleHwlabNodeUpdateHttp(request, response, url, options) {
|
|||||||
sendJson(response, 405, { ok: false, error: { code: "method_not_allowed", message: "GET required." } });
|
sendJson(response, 405, { ok: false, error: { code: "method_not_allowed", message: "GET required." } });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const env = options.env ?? process.env;
|
sendJson(response, 200, buildHwlabNodeUpdatePayload(url, options.env ?? process.env));
|
||||||
const currentVersion = cleanText(url.searchParams.get("current") || url.searchParams.get("version"));
|
|
||||||
const channel = cleanText(url.searchParams.get("channel")) || "stable";
|
|
||||||
const platform = cleanText(url.searchParams.get("platform")) || "unknown";
|
|
||||||
const bundled = hwlabNodeBundledMetadata(env);
|
|
||||||
const latestVersion = cleanText(env.HWLAB_NODE_PY_LATEST_VERSION) || bundled.version || "0.1.0";
|
|
||||||
const releaseNotesUrl = cleanText(env.HWLAB_NODE_PY_RELEASE_NOTES_URL) || null;
|
|
||||||
const downloadUrl = hwlabNodeDownloadUrl(env);
|
|
||||||
const sha256 = cleanText(env.HWLAB_NODE_PY_SHA256) || bundled.sha256 || null;
|
|
||||||
const fileName = nodeArtifactFileName(downloadUrl) || "hwlab-node.py";
|
|
||||||
const sizeBytes = parsePositiveInteger(env.HWLAB_NODE_PY_FILE_SIZE_BYTES, bundled.sizeBytes);
|
|
||||||
const newer = currentVersion ? compareVersionStrings(latestVersion, currentVersion) > 0 : Boolean(latestVersion);
|
|
||||||
const updateAvailable = Boolean(downloadUrl && newer);
|
|
||||||
sendJson(response, 200, {
|
|
||||||
ok: true,
|
|
||||||
contractVersion: "hwlab-node-update-v1",
|
|
||||||
serviceId: "hwlab-node",
|
|
||||||
route: "/v1/hwlab-node/update",
|
|
||||||
channel,
|
|
||||||
platform,
|
|
||||||
currentVersion: currentVersion || null,
|
|
||||||
latestVersion,
|
|
||||||
updateAvailable,
|
|
||||||
downloadUrl: updateAvailable ? downloadUrl : null,
|
|
||||||
sha256: updateAvailable ? sha256 : null,
|
|
||||||
artifact: {
|
|
||||||
version: latestVersion,
|
|
||||||
fileName,
|
|
||||||
sizeBytes,
|
|
||||||
sha256,
|
|
||||||
downloadUrl
|
|
||||||
},
|
|
||||||
releaseNotes: hwlabNodeReleaseNotes(env),
|
|
||||||
releaseNotesUrl,
|
|
||||||
installConditions: [
|
|
||||||
{ id: "windows-python", label: "Windows 交互用户的原生 Python", required: true },
|
|
||||||
{ id: "tkinter", label: "Python tkinter 图形能力", required: true },
|
|
||||||
{ id: "yaml-managed-config", label: "owning YAML 声明 nodeId、工作区与凭据引用", required: true },
|
|
||||||
{ id: "outbound-websocket", label: "可主动出站连接 Cloud API WebSocket", required: true }
|
|
||||||
],
|
|
||||||
readinessStages: hwlabNodeReadinessStages(),
|
|
||||||
manualDefault: true,
|
|
||||||
autoApplyDefault: false,
|
|
||||||
checkIntervalSeconds: parsePositiveInteger(env.HWLAB_NODE_PY_UPDATE_INTERVAL_SECONDS, 300),
|
|
||||||
observedAt: new Date().toISOString()
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function handleHwlabNodeDownloadHttp(request, response, options) {
|
export function handleHwlabNodeDownloadHttp(request, response, options) {
|
||||||
@@ -145,7 +96,7 @@ export function handleHwlabNodeDownloadHttp(request, response, options) {
|
|||||||
sendJson(response, 405, { ok: false, error: { code: "method_not_allowed", message: "GET required." } });
|
sendJson(response, 405, { ok: false, error: { code: "method_not_allowed", message: "GET required." } });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const bundled = hwlabNodeBundledMetadata(options.env ?? process.env);
|
const bundled = hwlabNodeBundledArtifact(options.env ?? process.env);
|
||||||
if (!bundled.content) {
|
if (!bundled.content) {
|
||||||
sendJson(response, 404, { ok: false, error: { code: "hwlab_node_bundle_missing", message: "bundled hwlab-node.py is not available" } });
|
sendJson(response, 404, { ok: false, error: { code: "hwlab_node_bundle_missing", message: "bundled hwlab-node.py is not available" } });
|
||||||
return;
|
return;
|
||||||
@@ -212,71 +163,6 @@ function hwpodNodeOpsRequestMeta(request, options, label) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function hwlabNodeBundledMetadata(env) {
|
|
||||||
const bundlePath = cleanText(env.HWLAB_NODE_PY_BUNDLE_PATH) || path.resolve(process.cwd(), "tools/hwlab-node.py");
|
|
||||||
try {
|
|
||||||
const content = readFileSync(bundlePath, "utf8");
|
|
||||||
const version = cleanText(content.match(/APP_VERSION\s*=\s*["']([^"']+)["']/u)?.[1]);
|
|
||||||
const sha256 = createHash("sha256").update(content, "utf8").digest("hex");
|
|
||||||
return { path: bundlePath, content, version, sha256, sizeBytes: Buffer.byteLength(content, "utf8") };
|
|
||||||
} catch {
|
|
||||||
return { path: bundlePath, content: "", version: "", sha256: "", sizeBytes: 0 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function nodeArtifactFileName(downloadUrl) {
|
|
||||||
try {
|
|
||||||
const name = path.posix.basename(new URL(downloadUrl).pathname);
|
|
||||||
return cleanText(name);
|
|
||||||
} catch {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function hwlabNodeReleaseNotes(env) {
|
|
||||||
const configured = cleanText(env.HWLAB_NODE_PY_RELEASE_NOTES);
|
|
||||||
if (configured) return configured.split(/\r?\n|\|/u).map((item) => item.trim()).filter(Boolean).slice(0, 12);
|
|
||||||
return ["单文件 Python/tkinter 节点,提供主动出站 WebSocket、有界诊断和 SHA-256 校验自更新。"];
|
|
||||||
}
|
|
||||||
|
|
||||||
function hwlabNodeReadinessStages() {
|
|
||||||
return HWPOD_NODE_READINESS_DEFINITIONS.map((stage) => ({ ...stage, nextAction: { ...stage.nextAction } }));
|
|
||||||
}
|
|
||||||
|
|
||||||
function hwlabNodeDownloadUrl(env) {
|
|
||||||
const explicit = cleanText(env.HWLAB_NODE_PY_DOWNLOAD_URL);
|
|
||||||
if (explicit) return explicit;
|
|
||||||
const downloadPath = cleanText(env.HWLAB_NODE_PY_DOWNLOAD_PATH) || "/v1/hwlab-node/download/hwlab-node.py";
|
|
||||||
if (/^https?:\/\//iu.test(downloadPath)) return downloadPath;
|
|
||||||
const publicEndpoint = cleanText(env.HWLAB_PUBLIC_ENDPOINT);
|
|
||||||
if (!publicEndpoint) return downloadPath.startsWith("/") ? downloadPath : `/${downloadPath}`;
|
|
||||||
try {
|
|
||||||
return new URL(downloadPath, publicEndpoint.endsWith("/") ? publicEndpoint : `${publicEndpoint}/`).toString();
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function compareVersionStrings(left, right) {
|
|
||||||
const a = versionParts(left);
|
|
||||||
const b = versionParts(right);
|
|
||||||
for (let index = 0; index < Math.max(a.length, b.length, 3); index += 1) {
|
|
||||||
const delta = (a[index] ?? 0) - (b[index] ?? 0);
|
|
||||||
if (delta !== 0) return delta > 0 ? 1 : -1;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function versionParts(value) {
|
|
||||||
return String(value ?? "")
|
|
||||||
.trim()
|
|
||||||
.replace(/^v/iu, "")
|
|
||||||
.split(/[.+-]/u)
|
|
||||||
.slice(0, 4)
|
|
||||||
.map((part) => parseInt(part, 10))
|
|
||||||
.map((part) => (Number.isFinite(part) && part >= 0 ? part : 0));
|
|
||||||
}
|
|
||||||
|
|
||||||
function cleanText(value) {
|
function cleanText(value) {
|
||||||
const text = String(value ?? "").trim();
|
const text = String(value ?? "").trim();
|
||||||
return text || "";
|
return text || "";
|
||||||
|
|||||||
+10
-1
@@ -10,14 +10,23 @@ import { compileHwpodNodeOpsPlan } from "../../tools/src/hwpod-harness-lib.ts";
|
|||||||
import { HWPOD_NODE_OPS, HWPOD_NODE_OPS_CONTRACT_VERSION } from "../../tools/src/hwpod-node-ops-contract.ts";
|
import { HWPOD_NODE_OPS, HWPOD_NODE_OPS_CONTRACT_VERSION } from "../../tools/src/hwpod-node-ops-contract.ts";
|
||||||
import { validateHwpodOperationInput, validateHwpodWebOperationRequest } from "./contracts.ts";
|
import { validateHwpodOperationInput, validateHwpodWebOperationRequest } from "./contracts.ts";
|
||||||
import { buildHwpodTopologyReadModel } from "../cloud/hwpod-topology-read-model.ts";
|
import { buildHwpodTopologyReadModel } from "../cloud/hwpod-topology-read-model.ts";
|
||||||
|
import { buildHwlabNodeUpdatePayload, hwlabNodeBundledArtifact } from "./node-artifact.ts";
|
||||||
|
|
||||||
export function createHwpodHttpApp(options: { nodeOpsApiUrl: string; temporal: any; specRegistry: any; nodeRegistry: any }) {
|
export function createHwpodHttpApp(options: { nodeOpsApiUrl: string; temporal: any; specRegistry: any; nodeRegistry: any; env?: Record<string, string | undefined> }) {
|
||||||
return {
|
return {
|
||||||
async fetch(request: Request) {
|
async fetch(request: Request) {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
try {
|
try {
|
||||||
if (url.pathname === "/health/live") return json(200, { ok: true, serviceId: "hwlab-hwpod-api", status: "live", valuesPrinted: false });
|
if (url.pathname === "/health/live") return json(200, { ok: true, serviceId: "hwlab-hwpod-api", status: "live", valuesPrinted: false });
|
||||||
if (url.pathname === "/health/ready") return readiness(options);
|
if (url.pathname === "/health/ready") return readiness(options);
|
||||||
|
if (url.pathname === "/v1/hwlab-node/update" && request.method === "GET") {
|
||||||
|
return json(200, buildHwlabNodeUpdatePayload(url, options.env ?? process.env));
|
||||||
|
}
|
||||||
|
if (url.pathname === "/v1/hwlab-node/download/hwlab-node.py" && request.method === "GET") {
|
||||||
|
const artifact = hwlabNodeBundledArtifact(options.env ?? process.env);
|
||||||
|
if (!artifact.content) return json(404, { ok: false, error: { code: "hwlab_node_bundle_missing", message: "bundled hwlab-node.py is not available" } });
|
||||||
|
return new Response(artifact.content, { status: 200, headers: { "content-type": "text/x-python; charset=utf-8", "cache-control": "no-store", "x-hwlab-node-version": artifact.version || "unknown", "x-hwlab-node-sha256": artifact.sha256 } });
|
||||||
|
}
|
||||||
if (url.pathname === "/v1/hwpod" && request.method === "GET") return json(200, { ok: true, serviceId: "hwlab-hwpod-api", executionAuthority: "temporal", nodeOpsApiUrl: options.nodeOpsApiUrl, valuesPrinted: false });
|
if (url.pathname === "/v1/hwpod" && request.method === "GET") return json(200, { ok: true, serviceId: "hwlab-hwpod-api", executionAuthority: "temporal", nodeOpsApiUrl: options.nodeOpsApiUrl, valuesPrinted: false });
|
||||||
if (url.pathname === "/v1/hwpod/specs" && request.method === "GET") return json(200, await options.specRegistry.list());
|
if (url.pathname === "/v1/hwpod/specs" && request.method === "GET") return json(200, await options.specRegistry.list());
|
||||||
if (url.pathname === "/v1/hwpod/specs" && request.method === "POST") {
|
if (url.pathname === "/v1/hwpod/specs" && request.method === "POST") {
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
/*
|
||||||
|
* SPEC: PJ2026-010103 HWPOD 服务;PJ2026-010405 云端控制台。
|
||||||
|
* 责任: 为 Cloud API 与独立 HWPOD API 提供同一 Python 节点 artifact 合同。
|
||||||
|
*/
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
import { HWPOD_NODE_READINESS_DEFINITIONS } from "../cloud/hwpod-topology-read-model.ts";
|
||||||
|
|
||||||
|
type Env = Record<string, string | undefined>;
|
||||||
|
|
||||||
|
export function buildHwlabNodeUpdatePayload(url: URL, env: Env = process.env) {
|
||||||
|
const currentVersion = cleanText(url.searchParams.get("current") || url.searchParams.get("version"));
|
||||||
|
const channel = cleanText(url.searchParams.get("channel")) || "stable";
|
||||||
|
const platform = cleanText(url.searchParams.get("platform")) || "unknown";
|
||||||
|
const bundled = hwlabNodeBundledArtifact(env);
|
||||||
|
const latestVersion = cleanText(env.HWLAB_NODE_PY_LATEST_VERSION) || bundled.version || "0.1.0";
|
||||||
|
const releaseNotesUrl = cleanText(env.HWLAB_NODE_PY_RELEASE_NOTES_URL) || null;
|
||||||
|
const downloadUrl = hwlabNodeDownloadUrl(env);
|
||||||
|
const sha256 = cleanText(env.HWLAB_NODE_PY_SHA256) || bundled.sha256 || null;
|
||||||
|
const fileName = nodeArtifactFileName(downloadUrl) || "hwlab-node.py";
|
||||||
|
const sizeBytes = positiveInteger(env.HWLAB_NODE_PY_FILE_SIZE_BYTES, bundled.sizeBytes);
|
||||||
|
const newer = currentVersion ? compareVersionStrings(latestVersion, currentVersion) > 0 : Boolean(latestVersion);
|
||||||
|
const updateAvailable = Boolean(downloadUrl && newer);
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
contractVersion: "hwlab-node-update-v1",
|
||||||
|
serviceId: "hwlab-node",
|
||||||
|
route: "/v1/hwlab-node/update",
|
||||||
|
channel,
|
||||||
|
platform,
|
||||||
|
currentVersion: currentVersion || null,
|
||||||
|
latestVersion,
|
||||||
|
updateAvailable,
|
||||||
|
downloadUrl: updateAvailable ? downloadUrl : null,
|
||||||
|
sha256: updateAvailable ? sha256 : null,
|
||||||
|
artifact: { version: latestVersion, fileName, sizeBytes, sha256, downloadUrl },
|
||||||
|
releaseNotes: hwlabNodeReleaseNotes(env),
|
||||||
|
releaseNotesUrl,
|
||||||
|
installConditions: [
|
||||||
|
{ id: "windows-python", label: "Windows 交互用户的原生 Python", required: true },
|
||||||
|
{ id: "tkinter", label: "Python tkinter 图形能力", required: true },
|
||||||
|
{ id: "yaml-managed-config", label: "owning YAML 声明 nodeId、工作区与凭据引用", required: true },
|
||||||
|
{ id: "outbound-websocket", label: "可主动出站连接 Cloud API WebSocket", required: true }
|
||||||
|
],
|
||||||
|
readinessStages: HWPOD_NODE_READINESS_DEFINITIONS.map((stage) => ({ ...stage, nextAction: { ...stage.nextAction } })),
|
||||||
|
manualDefault: true,
|
||||||
|
autoApplyDefault: false,
|
||||||
|
checkIntervalSeconds: positiveInteger(env.HWLAB_NODE_PY_UPDATE_INTERVAL_SECONDS, 300),
|
||||||
|
observedAt: new Date().toISOString()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hwlabNodeBundledArtifact(env: Env = process.env) {
|
||||||
|
const bundlePath = cleanText(env.HWLAB_NODE_PY_BUNDLE_PATH) || path.resolve(process.cwd(), "tools/hwlab-node.py");
|
||||||
|
try {
|
||||||
|
const content = readFileSync(bundlePath, "utf8");
|
||||||
|
const version = cleanText(content.match(/APP_VERSION\s*=\s*["']([^"']+)["']/u)?.[1]);
|
||||||
|
const sha256 = createHash("sha256").update(content, "utf8").digest("hex");
|
||||||
|
return { path: bundlePath, content, version, sha256, sizeBytes: Buffer.byteLength(content, "utf8") };
|
||||||
|
} catch {
|
||||||
|
return { path: bundlePath, content: "", version: "", sha256: "", sizeBytes: 0 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hwlabNodeDownloadUrl(env: Env) {
|
||||||
|
const explicit = cleanText(env.HWLAB_NODE_PY_DOWNLOAD_URL);
|
||||||
|
if (explicit) return explicit;
|
||||||
|
const downloadPath = cleanText(env.HWLAB_NODE_PY_DOWNLOAD_PATH) || "/v1/hwlab-node/download/hwlab-node.py";
|
||||||
|
if (/^https?:\/\//iu.test(downloadPath)) return downloadPath;
|
||||||
|
const publicEndpoint = cleanText(env.HWPOD_PUBLIC_BASE_URL) || cleanText(env.HWLAB_PUBLIC_ENDPOINT);
|
||||||
|
if (!publicEndpoint) return downloadPath.startsWith("/") ? downloadPath : `/${downloadPath}`;
|
||||||
|
try {
|
||||||
|
return new URL(downloadPath, publicEndpoint.endsWith("/") ? publicEndpoint : `${publicEndpoint}/`).toString();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodeArtifactFileName(downloadUrl: string | null) {
|
||||||
|
try { return cleanText(path.posix.basename(new URL(downloadUrl ?? "").pathname)); }
|
||||||
|
catch { return ""; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function hwlabNodeReleaseNotes(env: Env) {
|
||||||
|
const configured = cleanText(env.HWLAB_NODE_PY_RELEASE_NOTES);
|
||||||
|
if (configured) return configured.split(/\r?\n|\|/u).map((item) => item.trim()).filter(Boolean).slice(0, 12);
|
||||||
|
return ["单文件 Python/tkinter 节点,提供主动出站 WebSocket、有界诊断和 SHA-256 校验自更新。"];
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareVersionStrings(left: string, right: string) {
|
||||||
|
const a = versionParts(left);
|
||||||
|
const b = versionParts(right);
|
||||||
|
for (let index = 0; index < Math.max(a.length, b.length, 3); index += 1) {
|
||||||
|
const delta = (a[index] ?? 0) - (b[index] ?? 0);
|
||||||
|
if (delta !== 0) return delta > 0 ? 1 : -1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function versionParts(value: string) {
|
||||||
|
return value.trim().replace(/^v/iu, "").split(/[.+-]/u).slice(0, 4)
|
||||||
|
.map((part) => Number.parseInt(part, 10))
|
||||||
|
.map((part) => (Number.isFinite(part) && part >= 0 ? part : 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
function positiveInteger(value: unknown, fallback: number) {
|
||||||
|
const parsed = Number(value);
|
||||||
|
return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanText(value: unknown) {
|
||||||
|
const text = String(value ?? "").trim();
|
||||||
|
return text || "";
|
||||||
|
}
|
||||||
@@ -53,6 +53,32 @@ test("L1 HWPOD HTTP exposes runtime CRUD and typed frozen errors", async () => {
|
|||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("L1 HWPOD HTTP owns Python node metadata and download routes", async () => {
|
||||||
|
const registry = createHwpodSpecRegistry({ store: memoryStore() });
|
||||||
|
const temporal = { close: async () => {}, start: async () => ({}), describe: async () => ({}), result: async () => ({}) };
|
||||||
|
const app = createHwpodHttpApp({
|
||||||
|
nodeOpsApiUrl: "http://native-hwpod.test",
|
||||||
|
temporal,
|
||||||
|
specRegistry: registry,
|
||||||
|
nodeRegistry: nodeRegistry(),
|
||||||
|
env: { ...process.env, HWPOD_PUBLIC_BASE_URL: "https://lab-dev-hwpod.hwpod.com" },
|
||||||
|
});
|
||||||
|
|
||||||
|
const metadata = await request(app, "GET", "/v1/hwlab-node/update?platform=windows&channel=stable");
|
||||||
|
expect(metadata).toMatchObject({ status: 200, body: { ok: true, contractVersion: "hwlab-node-update-v1", artifact: { fileName: "hwlab-node.py" } } });
|
||||||
|
expect(metadata.body.artifact.version).toMatch(/^\d+\.\d+\.\d+$/u);
|
||||||
|
expect(metadata.body.artifact.sizeBytes).toBeGreaterThan(0);
|
||||||
|
expect(metadata.body.artifact.sha256).toMatch(/^[a-f0-9]{64}$/u);
|
||||||
|
expect(metadata.body.artifact.downloadUrl).toBe("https://lab-dev-hwpod.hwpod.com/v1/hwlab-node/download/hwlab-node.py");
|
||||||
|
|
||||||
|
const download = await app.fetch(new Request("http://hwpod.test/v1/hwlab-node/download/hwlab-node.py"));
|
||||||
|
expect(download.status).toBe(200);
|
||||||
|
expect(download.headers.get("content-type")).toContain("text/x-python");
|
||||||
|
expect(download.headers.get("x-hwlab-node-sha256")).toBe(metadata.body.artifact.sha256);
|
||||||
|
expect(await download.text()).toContain(`APP_VERSION = "${metadata.body.artifact.version}"`);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
test("L0 HWPOD topology is built from the local registry without Cloud API", async () => {
|
test("L0 HWPOD topology is built from the local registry without Cloud API", async () => {
|
||||||
const registry = createHwpodSpecRegistry({
|
const registry = createHwpodSpecRegistry({
|
||||||
store: memoryStore(),
|
store: memoryStore(),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
import { parseHwpodNativeAccessProfile } from "./hwpod-native-vite.config";
|
import { hwpodNativeProxy, parseHwpodNativeAccessProfile } from "./hwpod-native-vite.config";
|
||||||
import { firstAllowedNavPath } from "../src/stores/auth";
|
import { firstAllowedNavPath } from "../src/stores/auth";
|
||||||
|
|
||||||
describe("HWPOD native Cloud Console profile", () => {
|
describe("HWPOD native Cloud Console profile", () => {
|
||||||
@@ -17,4 +17,10 @@ describe("HWPOD native Cloud Console profile", () => {
|
|||||||
it("fails closed without a declared navigation profile", () => {
|
it("fails closed without a declared navigation profile", () => {
|
||||||
expect(() => parseHwpodNativeAccessProfile(undefined)).toThrow(/HWPOD_WEB_ACCESS_PROFILE_JSON/u);
|
expect(() => parseHwpodNativeAccessProfile(undefined)).toThrow(/HWPOD_WEB_ACCESS_PROFILE_JSON/u);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("proxies onboarding metadata and downloads to the independent HWPOD API", () => {
|
||||||
|
expect(hwpodNativeProxy("http://127.0.0.1:6681")).toMatchObject({
|
||||||
|
"/v1/hwlab-node": { target: "http://127.0.0.1:6681", changeOrigin: false },
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,17 +19,22 @@ export default defineConfig(({ command }) => ({
|
|||||||
host: requiredString(process.env.HWPOD_WEB_HOST, "HWPOD_WEB_HOST"),
|
host: requiredString(process.env.HWPOD_WEB_HOST, "HWPOD_WEB_HOST"),
|
||||||
port: requiredPort(process.env.HWPOD_WEB_PORT),
|
port: requiredPort(process.env.HWPOD_WEB_PORT),
|
||||||
strictPort: true,
|
strictPort: true,
|
||||||
proxy: {
|
proxy: hwpodNativeProxy(requiredApiUrl())
|
||||||
"/v1/hwpod-node/ws": { target: requiredApiUrl(), changeOrigin: false, ws: true },
|
|
||||||
"/v1/hwpod": { target: requiredApiUrl(), changeOrigin: false },
|
|
||||||
"/health/ready": { target: requiredApiUrl(), changeOrigin: false },
|
|
||||||
}
|
|
||||||
} : undefined,
|
} : undefined,
|
||||||
plugins: command === "serve"
|
plugins: command === "serve"
|
||||||
? [nativeRuntimeConfigPlugin(process.env.HWPOD_WEB_RUNTIME_CONFIG), nativeSessionPlugin(process.env.HWPOD_WEB_ACCESS_PROFILE_JSON), healthPlugin(), vue()]
|
? [nativeRuntimeConfigPlugin(process.env.HWPOD_WEB_RUNTIME_CONFIG), nativeSessionPlugin(process.env.HWPOD_WEB_ACCESS_PROFILE_JSON), healthPlugin(), vue()]
|
||||||
: [vue()]
|
: [vue()]
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
export function hwpodNativeProxy(target: string) {
|
||||||
|
return {
|
||||||
|
"/v1/hwpod-node/ws": { target, changeOrigin: false, ws: true },
|
||||||
|
"/v1/hwpod": { target, changeOrigin: false },
|
||||||
|
"/v1/hwlab-node": { target, changeOrigin: false },
|
||||||
|
"/health/ready": { target, changeOrigin: false },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function healthPlugin(): Plugin { return { name: "hwpod-native-health", configureServer(server) { server.middlewares.use((request, response, next) => { if (request.url !== "/health/live") return next(); response.statusCode = 200; response.setHeader("content-type", "application/json"); response.end(JSON.stringify({ ok: true, serviceId: "hwlab-hwpod-web", status: "live", valuesPrinted: false })); }); } }; }
|
function healthPlugin(): Plugin { return { name: "hwpod-native-health", configureServer(server) { server.middlewares.use((request, response, next) => { if (request.url !== "/health/live") return next(); response.statusCode = 200; response.setHeader("content-type", "application/json"); response.end(JSON.stringify({ ok: true, serviceId: "hwlab-hwpod-web", status: "live", valuesPrinted: false })); }); } }; }
|
||||||
function nativeRuntimeConfigPlugin(serialized: string | undefined): Plugin {
|
function nativeRuntimeConfigPlugin(serialized: string | undefined): Plugin {
|
||||||
const config = parseWorkbenchNativeRuntimeConfig(requiredString(serialized, "HWPOD_WEB_RUNTIME_CONFIG"));
|
const config = parseWorkbenchNativeRuntimeConfig(requiredString(serialized, "HWPOD_WEB_RUNTIME_CONFIG"));
|
||||||
|
|||||||
Reference in New Issue
Block a user