Files
pikasTech-unidesk/scripts/src/hwlab-hwpod-node.ts
T
Codex a3056e8cdf fix: 识别 Windows Python 实际节点进程
忽略 pyw 启动器,只按 python 解释器统计节点实例,避免误报重复进程。

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-07-10 13:19:31 +02:00

717 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 规格:PJ2026-010104 draft-2026-07-10-g14-wsl-python-hwpod-node。
// 职责:通过 YAML-first 管理 trans 可达的 Windows Python HWPOD 节点部署和状态。
import { createHash, randomBytes } from "node:crypto";
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, isAbsolute, join } from "node:path";
import { repoRoot, rootPath } from "./config";
import { runCommand, type CommandResult } from "./command";
type HwpodNodeAction = "plan" | "status" | "apply";
type HwpodNodeComponent = "all" | "cloud-auth" | "desktop";
interface HwpodNodeOptions {
action: HwpodNodeAction;
component: HwpodNodeComponent;
node: string;
lane: string;
confirm: boolean;
dryRun: boolean;
}
interface SecretSpec {
sourceRef: string;
sourceKey: string;
generateIfMissing: boolean;
secretName: string;
targetKey: string;
}
interface SourceSpec {
sourceId: string;
projectId: string;
hwpodId: string;
nodeId: string;
workspaceRootRef: string;
mdtodoRootRef: string;
capabilities: Record<string, boolean>;
}
interface HwpodNodeSpec {
configPath: string;
node: string;
lane: string;
providerId: string;
windowsRoute: string;
nodeId: string;
hwpodId: string;
interactiveUser: string;
publicOrigin: string;
nodeOpsUrl: string;
cloudKubeRoute: string;
cloudNamespace: string;
cloudSecret: SecretSpec;
opsAuth: { sourceRef: string; sourceKey: string };
python: {
probeLauncherPath: string;
guiLauncherPath: string;
launcherArgs: string[];
expectedVersionPrefix: string;
};
runtime: {
root: string;
scriptPath: string;
configPath: string;
credentialPath: string;
logPath: string;
};
artifact: { metadataUrl: string; channel: string; platform: string };
workspace: { allowedRoots: string[]; defaultRoot: string };
desktopConfig: {
serverUrl: string;
autoConnect: boolean;
requireCredential: boolean;
updateEnabled: boolean;
updateAutoApplyWhenIdle: boolean;
};
startup: { mode: string; runKeyName: string; initialLaunch: string; processPattern: string };
sourceRef: string;
source: SourceSpec;
}
interface ArtifactBundle {
latestVersion: string;
downloadUrl: string;
sha256: string;
content: Buffer;
}
const CONFIG_PATH = "config/hwlab-hwpod-nodes.yaml";
const SECRET_ROOT = rootPath(".state", "secrets");
export function hwlabHwpodNodeHelp(): Record<string, unknown> {
return {
ok: true,
command: "hwlab nodes hwpod-node",
description: "通过 YAML 和 trans 部署、查询 Windows 原生 Python HWPOD 节点。",
configPath: CONFIG_PATH,
actions: {
plan: "校验 YAML、SecretRef、发布文件元数据和 Windows 路由,不修改运行面。",
status: "查询 Windows 文件、解释器、交互会话、启动项、进程和云端节点操作状态。",
apply: "显式确认后同步云端认证 Secret 或部署 Windows Python 图形节点。",
},
components: {
"cloud-auth": "生成缺失的 YAML 声明凭据,并同步 cloud-api 使用的 Kubernetes Secret。",
desktop: "校验发布文件 SHA 后部署桌面节点、配置、凭据和 HKCU Run 启动项。",
all: "按 cloud-auth、desktop 顺序执行。",
},
examples: [
"bun scripts/cli.ts hwlab nodes hwpod-node plan --node G14-WSL --lane v03",
"bun scripts/cli.ts hwlab nodes hwpod-node status --node G14-WSL --lane v03",
"bun scripts/cli.ts hwlab nodes hwpod-node apply --node G14-WSL --lane v03 --component cloud-auth --confirm",
"bun scripts/cli.ts hwlab nodes hwpod-node apply --node G14-WSL --lane v03 --component desktop --confirm",
],
valuesRedacted: true,
};
}
export async function runHwlabHwpodNodeCommand(args: string[]): Promise<Record<string, unknown>> {
if (args.length === 0 || args.includes("--help") || args.includes("-h") || args[0] === "help") return hwlabHwpodNodeHelp();
const options = parseOptions(args);
const spec = readSpec(options.node, options.lane);
const artifact = await readArtifact(spec, false);
const secretBefore = readSecretMaterial(spec.cloudSecret);
const remoteBefore = remoteStatus(spec);
const graph = configGraph(spec, secretBefore, artifact, remoteBefore);
if (options.action === "plan") return resultEnvelope(options, spec, graph, null, null, null);
const cloudBefore = await cloudProbe(spec);
if (options.action === "status") return resultEnvelope(options, spec, graph, remoteBefore.payload, cloudBefore, null);
if (options.dryRun) return resultEnvelope(options, spec, graph, remoteBefore.payload, cloudBefore, { status: "dry-run", component: options.component });
if (!options.confirm) throw new Error("hwpod-node apply 必须显式使用 --confirm 或 --dry-run");
let cloudApply: Record<string, unknown> | null = null;
let desktopApply: Record<string, unknown> | null = null;
if (options.component === "all" || options.component === "cloud-auth") cloudApply = applyCloudAuth(spec);
if (options.component === "all" || options.component === "desktop") {
const secret = readSecretMaterial(spec.cloudSecret);
if (!secret.present || secret.value === null) throw new Error(`${spec.cloudSecret.sourceRef}.${spec.cloudSecret.sourceKey} 缺失;先应用 cloud-auth`);
const fullArtifact = await readArtifact(spec, true);
desktopApply = applyDesktop(spec, fullArtifact, secret.value);
}
await delay(2500);
const remoteAfter = remoteStatus(spec);
const cloudAfter = await cloudProbe(spec);
const applyResult = { status: "applied", component: options.component, cloudAuth: cloudApply, desktop: desktopApply, valuesRedacted: true };
return resultEnvelope(options, spec, configGraph(spec, readSecretMaterial(spec.cloudSecret), artifact, remoteAfter), remoteAfter.payload, cloudAfter, applyResult);
}
function parseOptions(args: string[]): HwpodNodeOptions {
const action = args[0];
if (action !== "plan" && action !== "status" && action !== "apply") {
throw new Error("hwpod-node 用法:plan|status|apply --node NODE --lane LANE [--component all|cloud-auth|desktop] [--confirm|--dry-run]");
}
const knownValue = new Set(["--node", "--lane", "--component"]);
const knownFlag = new Set(["--confirm", "--dry-run", "--full"]);
for (let index = 1; index < args.length; index += 1) {
const item = args[index];
if (knownValue.has(item)) {
index += 1;
if (args[index] === undefined) throw new Error(`${item} 缺少值`);
continue;
}
if (!knownFlag.has(item)) throw new Error(`hwpod-node 不支持参数 ${item}`);
}
const node = option(args, "--node");
const lane = option(args, "--lane");
const componentRaw = option(args, "--component", "all");
if (componentRaw !== "all" && componentRaw !== "cloud-auth" && componentRaw !== "desktop") throw new Error(`--component 不支持 ${componentRaw}`);
const confirm = args.includes("--confirm");
const dryRun = args.includes("--dry-run");
if (confirm && dryRun) throw new Error("--confirm 与 --dry-run 不能同时使用");
if (action !== "apply" && (confirm || dryRun)) throw new Error(`${action} 是只读动作,不接受 --confirm 或 --dry-run`);
return { action, component: componentRaw, node, lane, confirm, dryRun };
}
function option(args: string[], name: string, fallback?: string): string {
const index = args.indexOf(name);
if (index < 0) {
if (fallback !== undefined) return fallback;
throw new Error(`缺少 ${name}`);
}
const value = args[index + 1];
if (!value || value.startsWith("--")) throw new Error(`${name} 缺少值`);
return value;
}
function readSpec(node: string, lane: string): HwpodNodeSpec {
const config = yamlFile(CONFIG_PATH);
const cloud = record(config.cloud, `${CONFIG_PATH}#cloud`);
const targets = record(config.targets, `${CONFIG_PATH}#targets`);
const target = record(targets[node], `${CONFIG_PATH}#targets.${node}`);
if (target.enabled !== true) throw new Error(`${CONFIG_PATH}#targets.${node}.enabled 必须为 true`);
const targetLane = text(target.lane, `targets.${node}.lane`);
if (targetLane !== lane) throw new Error(`目标 ${node} 声明 lane=${targetLane},不是 ${lane}`);
const python = record(target.python, `targets.${node}.python`);
const runtime = record(target.runtime, `targets.${node}.runtime`);
const artifact = record(target.artifact, `targets.${node}.artifact`);
const workspace = record(target.workspace, `targets.${node}.workspace`);
const desktopConfig = record(target.desktopConfig, `targets.${node}.desktopConfig`);
const startup = record(target.startup, `targets.${node}.startup`);
const nodeAuth = record(cloud.nodeAuth, "cloud.nodeAuth");
const opsAuth = record(cloud.opsAuth, "cloud.opsAuth");
const sourceRef = text(target.projectManagementSourceRef, `targets.${node}.projectManagementSourceRef`);
const source = readSource(sourceRef);
const allowedRoots = texts(workspace.allowedRoots, `targets.${node}.workspace.allowedRoots`);
if (allowedRoots.length === 0) throw new Error(`targets.${node}.workspace.allowedRoots 不能为空`);
const spec: HwpodNodeSpec = {
configPath: CONFIG_PATH,
node,
lane,
providerId: text(target.providerId, `targets.${node}.providerId`),
windowsRoute: text(target.windowsRoute, `targets.${node}.windowsRoute`),
nodeId: text(target.nodeId, `targets.${node}.nodeId`),
hwpodId: text(target.hwpodId, `targets.${node}.hwpodId`),
interactiveUser: text(target.interactiveUser, `targets.${node}.interactiveUser`),
publicOrigin: text(cloud.publicOrigin, "cloud.publicOrigin"),
nodeOpsUrl: text(cloud.nodeOpsUrl, "cloud.nodeOpsUrl"),
cloudKubeRoute: text(cloud.kubeRoute, "cloud.kubeRoute"),
cloudNamespace: text(cloud.namespace, "cloud.namespace"),
cloudSecret: {
sourceRef: text(nodeAuth.sourceRef, "cloud.nodeAuth.sourceRef"),
sourceKey: text(nodeAuth.sourceKey, "cloud.nodeAuth.sourceKey"),
generateIfMissing: bool(nodeAuth.generateIfMissing, "cloud.nodeAuth.generateIfMissing"),
secretName: text(nodeAuth.secretName, "cloud.nodeAuth.secretName"),
targetKey: text(nodeAuth.targetKey, "cloud.nodeAuth.targetKey"),
},
opsAuth: { sourceRef: text(opsAuth.sourceRef, "cloud.opsAuth.sourceRef"), sourceKey: text(opsAuth.sourceKey, "cloud.opsAuth.sourceKey") },
python: {
probeLauncherPath: text(python.probeLauncherPath, `targets.${node}.python.probeLauncherPath`),
guiLauncherPath: text(python.guiLauncherPath, `targets.${node}.python.guiLauncherPath`),
launcherArgs: texts(python.launcherArgs, `targets.${node}.python.launcherArgs`),
expectedVersionPrefix: text(python.expectedVersionPrefix, `targets.${node}.python.expectedVersionPrefix`),
},
runtime: {
root: text(runtime.root, `targets.${node}.runtime.root`),
scriptPath: text(runtime.scriptPath, `targets.${node}.runtime.scriptPath`),
configPath: text(runtime.configPath, `targets.${node}.runtime.configPath`),
credentialPath: text(runtime.credentialPath, `targets.${node}.runtime.credentialPath`),
logPath: text(runtime.logPath, `targets.${node}.runtime.logPath`),
},
artifact: {
metadataUrl: text(artifact.metadataUrl, `targets.${node}.artifact.metadataUrl`),
channel: text(artifact.channel, `targets.${node}.artifact.channel`),
platform: text(artifact.platform, `targets.${node}.artifact.platform`),
},
workspace: { allowedRoots, defaultRoot: text(workspace.defaultRoot, `targets.${node}.workspace.defaultRoot`) },
desktopConfig: {
serverUrl: text(desktopConfig.serverUrl, `targets.${node}.desktopConfig.serverUrl`),
autoConnect: bool(desktopConfig.autoConnect, `targets.${node}.desktopConfig.autoConnect`),
requireCredential: bool(desktopConfig.requireCredential, `targets.${node}.desktopConfig.requireCredential`),
updateEnabled: bool(desktopConfig.updateEnabled, `targets.${node}.desktopConfig.updateEnabled`),
updateAutoApplyWhenIdle: bool(desktopConfig.updateAutoApplyWhenIdle, `targets.${node}.desktopConfig.updateAutoApplyWhenIdle`),
},
startup: {
mode: text(startup.mode, `targets.${node}.startup.mode`),
runKeyName: text(startup.runKeyName, `targets.${node}.startup.runKeyName`),
initialLaunch: text(startup.initialLaunch, `targets.${node}.startup.initialLaunch`),
processPattern: text(startup.processPattern, `targets.${node}.startup.processPattern`),
},
sourceRef,
source,
};
validateCrossRefs(spec);
return spec;
}
function readSource(ref: string): SourceSpec {
const parsed = configRef(ref);
const doc = yamlFile(parsed.file);
const value = pathValue(doc, parsed.path);
const source = record(value, ref);
const capabilitiesRaw = record(source.capabilities, `${ref}.capabilities`);
return {
sourceId: text(source.sourceId, `${ref}.sourceId`),
projectId: text(source.projectId, `${ref}.projectId`),
hwpodId: text(source.hwpodId, `${ref}.hwpodId`),
nodeId: text(source.nodeId, `${ref}.nodeId`),
workspaceRootRef: text(source.workspaceRootRef, `${ref}.workspaceRootRef`),
mdtodoRootRef: text(source.mdtodoRootRef, `${ref}.mdtodoRootRef`),
capabilities: Object.fromEntries(Object.entries(capabilitiesRaw).map(([key, value]) => [key, bool(value, `${ref}.capabilities.${key}`)])),
};
}
function validateCrossRefs(spec: HwpodNodeSpec): void {
const conflicts: string[] = [];
if (spec.source.nodeId !== spec.nodeId) conflicts.push(`source.nodeId=${spec.source.nodeId} != target.nodeId=${spec.nodeId}`);
if (spec.source.hwpodId !== spec.hwpodId) conflicts.push(`source.hwpodId=${spec.source.hwpodId} != target.hwpodId=${spec.hwpodId}`);
if (!spec.workspace.allowedRoots.includes(spec.workspace.defaultRoot)) conflicts.push("workspace.defaultRoot 不在 allowedRoots 中");
if (spec.source.workspaceRootRef !== spec.workspace.defaultRoot) conflicts.push("source.workspaceRootRef 与 workspace.defaultRoot 不一致");
if (spec.startup.mode !== "hkcu-run") conflicts.push(`startup.mode 仅支持 hkcu-run,实际为 ${spec.startup.mode}`);
if (!spec.windowsRoute.endsWith(":win")) conflicts.push("windowsRoute 必须指向 :win plane");
if (conflicts.length > 0) throw new Error(`HWPOD 节点配置冲突:${conflicts.join("")}`);
}
function configGraph(spec: HwpodNodeSpec, secret: ReturnType<typeof readSecretMaterial>, artifact: ArtifactBundle, remote: ReturnType<typeof remoteStatus>): Record<string, unknown> {
return {
ok: remote.reachable && artifact.sha256.length > 0 && (secret.present || spec.cloudSecret.generateIfMissing),
configPath: spec.configPath,
sourceRef: spec.sourceRef,
target: {
node: spec.node,
lane: spec.lane,
providerId: spec.providerId,
windowsRoute: spec.windowsRoute,
nodeId: spec.nodeId,
hwpodId: spec.hwpodId,
interactiveUser: spec.interactiveUser,
workspaceRoots: spec.workspace.allowedRoots,
mdtodoRootRef: spec.source.mdtodoRootRef,
},
cloudAuth: {
sourceRef: spec.cloudSecret.sourceRef,
sourceKey: spec.cloudSecret.sourceKey,
sourcePresent: secret.present,
fingerprint: secret.fingerprint,
secretName: spec.cloudSecret.secretName,
targetKey: spec.cloudSecret.targetKey,
generateIfMissing: spec.cloudSecret.generateIfMissing,
valuesRedacted: true,
},
artifact: { latestVersion: artifact.latestVersion, downloadUrl: artifact.downloadUrl, sha256: artifact.sha256, bytes: artifact.content.length },
route: { reachable: remote.reachable, exitCode: remote.result.exitCode, timedOut: remote.result.timedOut },
valuesRedacted: true,
};
}
function resultEnvelope(
options: HwpodNodeOptions,
spec: HwpodNodeSpec,
graph: Record<string, unknown>,
remote: Record<string, unknown> | null,
cloud: Record<string, unknown> | null,
apply: Record<string, unknown> | null,
): Record<string, unknown> {
const remoteReady = remote?.installed === true && remote?.processReady === true && remote?.interactiveProcessReady === true;
const cloudReady = cloud?.ok === true;
const ok = options.action === "plan" ? graph.ok === true : options.action === "status" ? remoteReady && cloudReady : apply?.status === "applied" && (options.component === "cloud-auth" || (remoteReady && cloudReady));
return {
ok,
command: `hwlab nodes hwpod-node ${options.action} --node ${spec.node} --lane ${spec.lane}${options.action === "apply" ? ` --component ${options.component}${options.confirm ? " --confirm" : " --dry-run"}` : ""}`,
status: ok ? options.action === "apply" ? "applied" : "ready" : "blocked",
graph,
remote,
cloud,
apply,
next: {
plan: `bun scripts/cli.ts hwlab nodes hwpod-node plan --node ${spec.node} --lane ${spec.lane}`,
status: `bun scripts/cli.ts hwlab nodes hwpod-node status --node ${spec.node} --lane ${spec.lane}`,
cloudAuth: `bun scripts/cli.ts hwlab nodes hwpod-node apply --node ${spec.node} --lane ${spec.lane} --component cloud-auth --confirm`,
desktop: `bun scripts/cli.ts hwlab nodes hwpod-node apply --node ${spec.node} --lane ${spec.lane} --component desktop --confirm`,
},
valuesRedacted: true,
};
}
async function readArtifact(spec: HwpodNodeSpec, includeContent: boolean): Promise<ArtifactBundle> {
const metadataUrl = new URL(spec.artifact.metadataUrl);
metadataUrl.searchParams.set("platform", spec.artifact.platform);
metadataUrl.searchParams.set("channel", spec.artifact.channel);
metadataUrl.searchParams.set("current", "0.0.0");
const metadataResponse = await fetch(metadataUrl, { signal: AbortSignal.timeout(15000) });
if (!metadataResponse.ok) throw new Error(`HWPOD 节点更新元数据返回 HTTP ${metadataResponse.status}`);
const metadata = record(await metadataResponse.json(), "HWPOD 节点更新元数据");
const downloadUrl = text(metadata.downloadUrl, "更新元数据.downloadUrl");
const sha256 = text(metadata.sha256, "更新元数据.sha256").toLowerCase();
const latestVersion = text(metadata.latestVersion, "更新元数据.latestVersion");
const download = new URL(downloadUrl);
if (download.hostname !== new URL(spec.publicOrigin).hostname || !download.pathname.endsWith(".py")) throw new Error("更新元数据下载地址不属于 YAML 声明的公共入口或不是 .py 文件");
if (!includeContent) return { latestVersion, downloadUrl, sha256, content: Buffer.alloc(0) };
const response = await fetch(download, { signal: AbortSignal.timeout(30000) });
if (!response.ok) throw new Error(`HWPOD 节点文件下载返回 HTTP ${response.status}`);
const content = Buffer.from(await response.arrayBuffer());
const actual = createHash("sha256").update(content).digest("hex");
if (actual !== sha256) throw new Error(`HWPOD 节点文件 SHA 不匹配:expected=${sha256} actual=${actual}`);
return { latestVersion, downloadUrl, sha256, content };
}
function readSecretMaterial(spec: { sourceRef: string; sourceKey: string }): { present: boolean; value: string | null; fingerprint: string | null; sourcePath: string } {
const sourcePath = secretPath(spec.sourceRef);
if (!existsSync(sourcePath)) return { present: false, value: null, fingerprint: null, sourcePath };
const values = parseEnv(readFileSync(sourcePath, "utf8"));
const value = values[spec.sourceKey] ?? null;
return { present: value !== null && value.length > 0, value, fingerprint: value ? sha256(value) : null, sourcePath };
}
function ensureSecretMaterial(spec: SecretSpec): { value: string; fingerprint: string; generated: boolean; sourcePath: string } {
const current = readSecretMaterial(spec);
if (current.present && current.value) return { value: current.value, fingerprint: current.fingerprint ?? sha256(current.value), generated: false, sourcePath: current.sourcePath };
if (!spec.generateIfMissing) throw new Error(`${spec.sourceRef}.${spec.sourceKey} 缺失且 generateIfMissing=false`);
const value = randomBytes(32).toString("base64url");
const sourcePath = secretPath(spec.sourceRef);
mkdirSync(dirname(sourcePath), { recursive: true });
const existing = existsSync(sourcePath) ? parseEnv(readFileSync(sourcePath, "utf8")) : {};
existing[spec.sourceKey] = value;
writeFileSync(sourcePath, `${Object.entries(existing).map(([key, item]) => `${key}=${item}`).join("\n")}\n`, { encoding: "utf8", mode: 0o600 });
chmodSync(sourcePath, 0o600);
return { value, fingerprint: sha256(value), generated: true, sourcePath };
}
function applyCloudAuth(spec: HwpodNodeSpec): Record<string, unknown> {
const material = ensureSecretMaterial(spec.cloudSecret);
const manifest = JSON.stringify({
apiVersion: "v1",
kind: "Secret",
metadata: { name: spec.cloudSecret.secretName, namespace: spec.cloudNamespace, labels: { "app.kubernetes.io/managed-by": "unidesk", "hwlab.pikastech.local/config": "hwpod-node-auth" } },
type: "Opaque",
data: { [spec.cloudSecret.targetKey]: Buffer.from(material.value, "utf8").toString("base64") },
});
const result = runCommand(["trans", spec.cloudKubeRoute, "kubectl", "-n", spec.cloudNamespace, "apply", "-f", "-"], repoRoot, { input: manifest, timeoutMs: 55000 });
if (!commandSucceeded(result)) throw new Error(`同步 HWPOD 节点认证 Secret 失败:${compactFailure(result)}`);
return {
ok: true,
status: "applied",
generated: material.generated,
sourceRef: spec.cloudSecret.sourceRef,
sourceKey: spec.cloudSecret.sourceKey,
fingerprint: material.fingerprint,
secretName: spec.cloudSecret.secretName,
targetKey: spec.cloudSecret.targetKey,
valuesRedacted: true,
};
}
function applyDesktop(spec: HwpodNodeSpec, artifact: ArtifactBundle, credential: string): Record<string, unknown> {
const config = {
schemaVersion: 1,
serverUrl: spec.desktopConfig.serverUrl,
nodeId: spec.nodeId,
autoConnect: spec.desktopConfig.autoConnect,
requireCredential: spec.desktopConfig.requireCredential,
credentialFile: spec.runtime.credentialPath,
allowedWorkspaceRoots: spec.workspace.allowedRoots,
defaultWorkspaceRoot: spec.workspace.defaultRoot,
update: {
enabled: spec.desktopConfig.updateEnabled,
autoApplyWhenIdle: spec.desktopConfig.updateAutoApplyWhenIdle,
channel: spec.artifact.channel,
metadataUrl: spec.artifact.metadataUrl,
downloadHostAllowlist: [new URL(spec.publicOrigin).hostname],
},
};
const payload = {
interactiveUser: spec.interactiveUser,
runtimeRoot: spec.runtime.root,
scriptPath: spec.runtime.scriptPath,
configPath: spec.runtime.configPath,
credentialPath: spec.runtime.credentialPath,
logPath: spec.runtime.logPath,
probeLauncherPath: spec.python.probeLauncherPath,
guiLauncherPath: spec.python.guiLauncherPath,
launcherArgs: spec.python.launcherArgs,
expectedVersionPrefix: spec.python.expectedVersionPrefix,
runKeyName: spec.startup.runKeyName,
artifactBase64: artifact.content.toString("base64"),
artifactSha256: artifact.sha256,
configBase64: Buffer.from(`${JSON.stringify(config, null, 2)}\n`, "utf8").toString("base64"),
configSha256: sha256Hex(`${JSON.stringify(config, null, 2)}\n`),
credentialBase64: Buffer.from(`${credential}\n`, "utf8").toString("base64"),
};
const result = runPowerShell(spec.windowsRoute, desktopApplyScript(payload), 55000);
if (!commandSucceeded(result)) throw new Error(`部署 Windows HWPOD 节点失败:${compactFailure(result)}`);
const parsed = parseJsonRecord(result.stdout);
if (parsed?.ok !== true) throw new Error(`Windows HWPOD 节点部署未就绪:${JSON.stringify(parsed ?? { stdout: result.stdout.slice(-600) })}`);
return { ...parsed, artifactVersion: artifact.latestVersion, artifactSha256: artifact.sha256, valuesRedacted: true };
}
function remoteStatus(spec: HwpodNodeSpec): { reachable: boolean; payload: Record<string, unknown> | null; result: CommandResult } {
const payload = {
interactiveUser: spec.interactiveUser,
runtimeRoot: spec.runtime.root,
scriptPath: spec.runtime.scriptPath,
configPath: spec.runtime.configPath,
credentialPath: spec.runtime.credentialPath,
logPath: spec.runtime.logPath,
probeLauncherPath: spec.python.probeLauncherPath,
launcherArgs: spec.python.launcherArgs,
expectedVersionPrefix: spec.python.expectedVersionPrefix,
runKeyName: spec.startup.runKeyName,
};
const result = runPowerShell(spec.windowsRoute, desktopStatusScript(payload), 45000);
const reachable = commandSucceeded(result);
return { reachable, payload: reachable ? parseJsonRecord(result.stdout) : null, result };
}
async function cloudProbe(spec: HwpodNodeSpec): Promise<Record<string, unknown>> {
const auth = readSecretMaterial(spec.opsAuth);
if (!auth.present || !auth.value) return { ok: false, status: "blocked", blocker: "ops-auth-source-missing", sourceRef: spec.opsAuth.sourceRef, valuesRedacted: true };
const body = {
contractVersion: "hwpod-node-ops-v1",
planId: `hwpod_status_${Date.now()}`,
hwpodId: spec.hwpodId,
nodeId: spec.nodeId,
intent: "node.version",
ops: [{ opId: "op_status", op: "node.version", args: {} }],
};
try {
const response = await fetch(spec.nodeOpsUrl, {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${auth.value}`, "x-source-service-id": "unidesk-hwpod-node-cli" },
body: JSON.stringify(body),
signal: AbortSignal.timeout(15000),
});
const value = record(await response.json(), "node-ops status response");
const result = Array.isArray(value.results) ? record(value.results[0], "node-ops result") : {};
return {
ok: response.ok && value.ok === true && result.ok === true,
httpStatus: response.status,
status: value.status ?? null,
nodeId: spec.nodeId,
nodeVersion: record(result.output ?? {}, "node-ops output").version ?? null,
blocker: result.blocker ?? value.blocker ?? value.error ?? null,
requestId: record(value.requestMeta ?? {}, "requestMeta").requestId ?? null,
valuesRedacted: true,
};
} catch (error) {
return { ok: false, status: "blocked", blocker: error instanceof Error ? error.message : String(error), valuesRedacted: true };
}
}
function desktopStatusScript(payload: Record<string, unknown>): string {
return powerShellPayload(payload, `
$ErrorActionPreference = 'Stop'
$expectedUser = [string]$payload.interactiveUser
$scriptPath = [string]$payload.scriptPath
$configPath = [string]$payload.configPath
$credentialPath = [string]$payload.credentialPath
$logPath = [string]$payload.logPath
$probe = [string]$payload.probeLauncherPath
$launcherArgs = @($payload.launcherArgs | ForEach-Object { [string]$_ })
$versionOut = @(& $probe @launcherArgs -c "import sys;print(sys.version);print(sys.executable)" 2>&1)
$versionExit = $LASTEXITCODE
$processes = @(Get-CimInstance Win32_Process | Where-Object { $_.Name -like 'python*.exe' -and $_.CommandLine -and $_.CommandLine.Contains($scriptPath) })
$explorerSessions = @(Get-Process explorer -ErrorAction SilentlyContinue | Select-Object -ExpandProperty SessionId -Unique)
$interactive = @($processes | Where-Object { $explorerSessions -contains $_.SessionId })
$runValue = (Get-ItemProperty -Path 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Run' -Name ([string]$payload.runKeyName) -ErrorAction SilentlyContinue).([string]$payload.runKeyName)
function HashOrNull([string]$path) { if (Test-Path $path) { return 'sha256:' + (Get-FileHash -Algorithm SHA256 $path).Hash.ToLowerInvariant() }; return $null }
$result = [ordered]@{
ok = $true
status = 'observed'
user = $env:USERNAME
expectedUser = $expectedUser
userMatches = [bool]($env:USERNAME -ieq $expectedUser)
pythonReady = [bool]((Test-Path $probe) -and ($versionExit -eq 0) -and (($versionOut -join "\`n") -like "*$($payload.expectedVersionPrefix)*"))
pythonSummary = (($versionOut | Select-Object -First 2) -join ' | ')
installed = [bool]((Test-Path $scriptPath) -and (Test-Path $configPath) -and (Test-Path $credentialPath))
scriptSha256 = HashOrNull $scriptPath
configSha256 = HashOrNull $configPath
credentialPresent = [bool](Test-Path $credentialPath)
credentialFingerprint = HashOrNull $credentialPath
runKeyReady = [bool](-not [string]::IsNullOrWhiteSpace([string]$runValue))
processCount = [int]$processes.Count
processReady = [bool]($processes.Count -eq 1)
interactiveProcessCount = [int]$interactive.Count
interactiveProcessReady = [bool]($interactive.Count -eq 1)
processIds = @($processes | ForEach-Object { [int]$_.ProcessId })
explorerSessions = @($explorerSessions)
logPresent = [bool](Test-Path $logPath)
valuesRedacted = $true
}
$result | ConvertTo-Json -Compress -Depth 6
`);
}
function desktopApplyScript(payload: Record<string, unknown>): string {
return powerShellPayload(payload, `
$ErrorActionPreference = 'Stop'
if ($env:USERNAME -ine [string]$payload.interactiveUser) { throw "interactive user mismatch expected=$($payload.interactiveUser) actual=$env:USERNAME" }
$root = [string]$payload.runtimeRoot
$scriptPath = [string]$payload.scriptPath
$configPath = [string]$payload.configPath
$credentialPath = [string]$payload.credentialPath
$logPath = [string]$payload.logPath
$probe = [string]$payload.probeLauncherPath
$gui = [string]$payload.guiLauncherPath
$launcherArgs = @($payload.launcherArgs | ForEach-Object { [string]$_ })
if (-not (Test-Path $probe)) { throw "Python probe launcher missing: $probe" }
if (-not (Test-Path $gui)) { throw "Python GUI launcher missing: $gui" }
$versionOut = @(& $probe @launcherArgs -c "import sys;import tkinter;print(sys.version);print(sys.executable);print(tkinter.TkVersion)" 2>&1)
if ($LASTEXITCODE -ne 0 -or (($versionOut -join "\`n") -notlike "*$($payload.expectedVersionPrefix)*")) { throw "Python version mismatch: $($versionOut -join ' | ')" }
New-Item -ItemType Directory -Force -Path $root, (Split-Path -Parent $configPath), (Split-Path -Parent $credentialPath), (Split-Path -Parent $logPath) | Out-Null
$old = @(Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -and $_.CommandLine.Contains($scriptPath) })
foreach ($proc in $old) { Stop-Process -Id $proc.ProcessId -Force -ErrorAction SilentlyContinue }
Start-Sleep -Milliseconds 500
[IO.File]::WriteAllBytes($scriptPath, [Convert]::FromBase64String([string]$payload.artifactBase64))
[IO.File]::WriteAllBytes($configPath, [Convert]::FromBase64String([string]$payload.configBase64))
[IO.File]::WriteAllBytes($credentialPath, [Convert]::FromBase64String([string]$payload.credentialBase64))
$artifactActual = (Get-FileHash -Algorithm SHA256 $scriptPath).Hash.ToLowerInvariant()
$configActual = (Get-FileHash -Algorithm SHA256 $configPath).Hash.ToLowerInvariant()
if ($artifactActual -ne [string]$payload.artifactSha256) { throw "artifact SHA mismatch" }
if ($configActual -ne [string]$payload.configSha256) { throw "config SHA mismatch" }
$null = & icacls $credentialPath /inheritance:r /grant:r "$env:USERNAME\`:(R,W)" 2>&1
$argText = (($launcherArgs | ForEach-Object { '"' + ($_ -replace '"','\"') + '"' }) -join ' ')
$runCommand = '"' + $gui + '" ' + $argText + ' "' + $scriptPath + '"'
$null = New-Item -Path 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Run' -Force
Set-ItemProperty -Path 'HKCU:\\Software\\Microsoft\\Windows\\CurrentVersion\\Run' -Name ([string]$payload.runKeyName) -Value $runCommand
$stdoutPath = $logPath + '.stdout'
$stderrPath = $logPath + '.stderr'
Start-Process -FilePath $gui -ArgumentList @($launcherArgs + @($scriptPath)) -WorkingDirectory $root -RedirectStandardOutput $stdoutPath -RedirectStandardError $stderrPath
Start-Sleep -Seconds 3
$processes = @(Get-CimInstance Win32_Process | Where-Object { $_.Name -like 'python*.exe' -and $_.CommandLine -and $_.CommandLine.Contains($scriptPath) })
$explorerSessions = @(Get-Process explorer -ErrorAction SilentlyContinue | Select-Object -ExpandProperty SessionId -Unique)
$interactive = @($processes | Where-Object { $explorerSessions -contains $_.SessionId })
$result = [ordered]@{
ok = [bool](($processes.Count -eq 1) -and ($interactive.Count -eq 1))
status = $(if (($processes.Count -eq 1) -and ($interactive.Count -eq 1)) { 'applied' } else { 'installed-not-interactive' })
pythonSummary = (($versionOut | Select-Object -First 3) -join ' | ')
scriptSha256 = 'sha256:' + $artifactActual
configSha256 = 'sha256:' + $configActual
credentialPresent = $true
processCount = [int]$processes.Count
interactiveProcessCount = [int]$interactive.Count
processIds = @($processes | ForEach-Object { [int]$_.ProcessId })
runKeyReady = $true
stoppedProcessIds = @($old | ForEach-Object { [int]$_.ProcessId })
valuesRedacted = $true
}
$result | ConvertTo-Json -Compress -Depth 6
`);
}
function powerShellPayload(payload: Record<string, unknown>, body: string): string {
const encoded = Buffer.from(JSON.stringify(payload), "utf8").toString("base64");
return `$payload = ([Text.Encoding]::UTF8.GetString([Convert]::FromBase64String('${encoded}')) | ConvertFrom-Json)\n${body.trim()}\n`;
}
function runPowerShell(route: string, script: string, timeoutMs: number): CommandResult {
return runCommand(["trans", route, "ps"], repoRoot, { input: script, timeoutMs });
}
function yamlFile(path: string): Record<string, unknown> {
const absolute = rootPath(path);
if (!existsSync(absolute)) throw new Error(`${path} 不存在`);
return record(Bun.YAML.parse(readFileSync(absolute, "utf8")) as unknown, path);
}
function configRef(ref: string): { file: string; path: string } {
const parts = ref.split("#");
if (parts.length !== 2 || !parts[0] || !parts[1]) throw new Error(`配置引用必须使用 path.yaml#object.path${ref}`);
if (isAbsolute(parts[0]) || parts[0].includes("..")) throw new Error(`配置引用文件必须是仓库内相对路径:${ref}`);
return { file: parts[0], path: parts[1] };
}
function pathValue(input: unknown, path: string): unknown {
let current = input;
for (const raw of path.replace(/\[(\d+)\]/gu, ".$1").split(".").filter(Boolean)) {
if (Array.isArray(current)) current = current[Number(raw)];
else if (current && typeof current === "object") current = (current as Record<string, unknown>)[raw];
else return undefined;
}
return current;
}
function record(value: unknown, path: string): Record<string, any> {
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`${path} 必须是对象`);
return value as Record<string, any>;
}
function text(value: unknown, path: string): string {
if (typeof value !== "string" || value.trim().length === 0) throw new Error(`${path} 必须是非空字符串`);
return value.trim();
}
function texts(value: unknown, path: string): string[] {
if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || item.trim().length === 0)) throw new Error(`${path} 必须是非空字符串数组`);
return value.map((item) => String(item));
}
function bool(value: unknown, path: string): boolean {
if (typeof value !== "boolean") throw new Error(`${path} 必须是布尔值`);
return value;
}
function secretPath(sourceRef: string): string {
if (isAbsolute(sourceRef) || sourceRef.includes("..")) throw new Error(`Secret sourceRef 必须是相对路径且不能包含 ..:${sourceRef}`);
return join(SECRET_ROOT, sourceRef);
}
function parseEnv(content: string): Record<string, string> {
const result: Record<string, string> = {};
for (const raw of content.split(/\r?\n/u)) {
const line = raw.trim();
if (!line || line.startsWith("#")) continue;
const index = line.indexOf("=");
if (index <= 0) continue;
result[line.slice(0, index).trim()] = line.slice(index + 1).trim().replace(/^['"]|['"]$/gu, "");
}
return result;
}
function parseJsonRecord(textValue: string): Record<string, unknown> | null {
const lines = textValue.trim().split(/\r?\n/u).filter(Boolean);
for (let index = lines.length - 1; index >= 0; index -= 1) {
try {
return record(JSON.parse(lines[index]) as unknown, "远端 JSON");
} catch {}
}
return null;
}
function compactFailure(result: CommandResult): string {
const reason = result.timedOut ? "command-timed-out" : "command-failed";
return `${reason}; exit=${result.exitCode ?? "-"}; stderr=${result.stderr.trim().slice(-500)}`;
}
function sha256(value: string | Buffer): string {
return `sha256:${sha256Hex(value)}`;
}
function sha256Hex(value: string | Buffer): string {
return createHash("sha256").update(value).digest("hex");
}
function commandSucceeded(result: CommandResult): boolean {
return result.exitCode === 0 && !result.timedOut;
}
function delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}