Merge pull request #1694 from pikasTech/feat/g14-wsl-hwpod-node
Pipelines as Code CI / hwlab-web-probe-sentinel-nc01- Success
Pipelines as Code CI / unidesk-host- Success

feat: 增加 G14 Windows HWPOD 节点受控部署
This commit is contained in:
Lyon
2026-07-10 19:04:56 +08:00
committed by GitHub
9 changed files with 922 additions and 3 deletions
+712
View File
@@ -0,0 +1,712 @@
// 规格: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,
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 { $_.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
$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) | 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
Start-Process -FilePath $gui -ArgumentList @($launcherArgs + @($scriptPath)) -WorkingDirectory $root
Start-Sleep -Seconds 3
$processes = @(Get-CimInstance Win32_Process | Where-Object { $_.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));
}
+2
View File
@@ -19,6 +19,7 @@ export function hwlabNodeHelp(): Record<string, unknown> {
"bun scripts/cli.ts hwlab nodes control-plane cleanup-legacy-docker-registry-volume --node JD01 --lane v03 --dry-run",
"bun scripts/cli.ts hwlab nodes git-mirror status --node <node> --lane <lane>",
"bun scripts/cli.ts hwlab nodes hwpod-preinstall plan --node <node> --lane <lane> --dry-run",
"bun scripts/cli.ts hwlab nodes hwpod-node plan --node G14-WSL --lane v03",
"bun scripts/cli.ts hwlab nodes fake-model-provider plan --node D518 --lane v03 --provider fake-echo",
"bun scripts/cli.ts hwlab nodes secret status --node <node> --lane <lane> --name <secret>",
"bun scripts/cli.ts hwlab nodes test-accounts status --node <node> --lane <lane>",
@@ -29,6 +30,7 @@ export function hwlabNodeHelp(): Record<string, unknown> {
"control-plane": "YAML-first node-local CI/CD, git-mirror, source-workspace sync, public exposure, runtime-image, Argo, PipelineRun and CI workspace retention operations.",
"git-mirror": "Inspect or operate the selected node/lane source mirror.",
"hwpod-preinstall": "Render YAML-first HWPOD preinstall configRefs, runtime mount targets, PM MDTODO source, and gateway profile status.",
"hwpod-node": "通过 YAML 和 trans 部署、查询 Windows 原生 Python HWPOD 节点。",
"fake-model-provider": "Materialize and operate YAML-declared fake Responses model providers for HWLAB/AgentRun sentinel checks.",
secret: "Inspect and sync YAML-declared runtime Secrets without printing secret values.",
"test-accounts": "Prepare YAML-declared HWLAB admin/test account API keys with redacted sourceRef/fingerprint output.",
+5 -1
View File
@@ -554,6 +554,10 @@ export async function runHwlabNodeCommand(_config: Config, args: string[]): Prom
const { runHwlabNodeHwpodPreinstallCommand } = await import("../hwlab-node-hwpod-preinstall");
return runHwlabNodeHwpodPreinstallCommand(args.slice(1));
}
if (domain === "hwpod-node") {
const { runHwlabHwpodNodeCommand } = await import("../hwlab-hwpod-node");
return runHwlabHwpodNodeCommand(args.slice(1));
}
if (domain === "fake-model-provider") {
return runHwlabFakeModelProviderCommand(_config, args.slice(1));
}
@@ -569,7 +573,7 @@ export async function runHwlabNodeCommand(_config: Config, args: string[]): Prom
return runNodeDelegatedDomain(_config, domain, args.slice(1));
}
if (domain !== "secret") {
return { ok: false, command: `hwlab nodes ${domain ?? ""}`.trim(), message: "supported commands: hwlab nodes control-plane, hwlab nodes git-mirror, hwlab nodes hwpod-preinstall, hwlab nodes fake-model-provider, hwlab nodes observability, hwlab nodes secret, hwlab nodes test-accounts. web-probe moved to top-level: bun scripts/cli.ts web-probe --help" };
return { ok: false, command: `hwlab nodes ${domain ?? ""}`.trim(), message: "supported commands: hwlab nodes control-plane, hwlab nodes git-mirror, hwlab nodes hwpod-preinstall, hwlab nodes hwpod-node, hwlab nodes fake-model-provider, hwlab nodes observability, hwlab nodes secret, hwlab nodes test-accounts. web-probe moved to top-level: bun scripts/cli.ts web-probe --help" };
}
const options = parseSecretOptions(args.slice(1));
return runNodeSecret(options);