a3056e8cdf
忽略 pyw 启动器,只按 python 解释器统计节点实例,避免误报重复进程。 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
717 lines
36 KiB
TypeScript
717 lines
36 KiB
TypeScript
// 规格: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));
|
||
}
|