fix: 恢复 HWPOD 节点接入产物路由

This commit is contained in:
root
2026-07-21 13:48:31 +02:00
parent e2db65935d
commit b1d20178db
8 changed files with 181 additions and 131 deletions
+5 -119
View File
@@ -3,9 +3,7 @@
* Implementation reference: draft-2026-07-13-p0-cloud-console。
* Responsibility: 提供 HWLAB Node 更新、HWPOD typed topology、显式有界 readiness 探测与 node-ops 转发。
*/
import { createHash, randomUUID } from "node:crypto";
import { readFileSync } from "node:fs";
import path from "node:path";
import { randomUUID } from "node:crypto";
import { ENVIRONMENT_DEV } from "../protocol/index.mjs";
import { CLOUD_API_SERVICE_ID } from "../audit/index.mjs";
@@ -14,10 +12,8 @@ import {
hwpodSpecDiscoveryPayload,
hwpodSpecWorkspaceProbePlan
} from "./hwpod-spec-discovery.ts";
import {
buildHwpodTopologyReadModel,
HWPOD_NODE_READINESS_DEFINITIONS
} from "./hwpod-topology-read-model.ts";
import { buildHwpodTopologyReadModel } from "./hwpod-topology-read-model.ts";
import { buildHwlabNodeUpdatePayload, hwlabNodeBundledArtifact } from "../hwpod/node-artifact.ts";
import {
getHeader,
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." } });
return;
}
const env = 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()
});
sendJson(response, 200, buildHwlabNodeUpdatePayload(url, options.env ?? process.env));
}
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." } });
return;
}
const bundled = hwlabNodeBundledMetadata(options.env ?? process.env);
const bundled = hwlabNodeBundledArtifact(options.env ?? process.env);
if (!bundled.content) {
sendJson(response, 404, { ok: false, error: { code: "hwlab_node_bundle_missing", message: "bundled hwlab-node.py is not available" } });
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) {
const text = String(value ?? "").trim();
return text || "";