Files
pikasTech-unidesk/scripts/src/platform-infra-hyperv-vm.ts
T
2026-07-20 09:50:06 +02:00

430 lines
21 KiB
TypeScript

import { cpSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { join } from "node:path";
import type { UniDeskConfig } from "./config";
import { rootPath, stateRoot } from "./config";
import { runCommand, runCommandObserved } from "./command";
const configLabel = "config/platform-infra/hyperv-vms.yaml";
const configPath = rootPath(configLabel);
const bootstrapScriptPath = rootPath("scripts/assets/platform-infra/hyperv-vm-bootstrap.ps1");
const providerInstallTemplatePath = rootPath("scripts/assets/platform-infra/install-provider.sh");
interface HyperVConfig {
defaults: { targetId: string };
targets: Record<string, HyperVTarget>;
}
interface HyperVTarget {
enabled: boolean;
host: { route: string; computerName: string; stateDir: string; vmRoot: string; minimumFreeBytesBeforeInstall: number };
vm: Record<string, unknown> & { name: string; processorCount: number; memoryStartupBytes: number; memoryMinimumBytes: number; memoryMaximumBytes: number; hostReservedMemoryBytes: number; diskBytes: number };
image: Record<string, unknown> & { fileName: string; format: string; sha256: string; sources: Array<{ id: string; region: string; url: string }> };
network: Record<string, unknown> & { address: string };
ubuntu: Record<string, unknown> & { user: string };
docker: Record<string, unknown>;
k3s: Record<string, unknown> & { nodeName: string };
provider: Record<string, unknown> & {
id: string;
name: string;
masterServer: string;
root: string;
stateRoot: string;
composeProject: string;
containerName: string;
image: string;
systemdUnit: string;
};
verification: { probeTimeoutSeconds: number; repairReadyTimeoutSeconds: number; repairRetryIntervalSeconds: number };
status: Record<string, unknown>;
}
interface Options {
action: "plan" | "apply" | "status" | "logs" | "verify";
targetId: string;
confirm: boolean;
repairProviderChannel: boolean;
tail: number;
}
interface VerifyRoutesResult extends Record<string, unknown> {
ok: boolean;
repairable: boolean;
failureKind: string | null;
}
export async function runPlatformInfraHyperVVmCommand(_config: UniDeskConfig, args: string[]): Promise<Record<string, unknown>> {
const parsed = readConfig();
const options = parseOptions(args, parsed.defaults.targetId);
const target = parsed.targets[options.targetId];
if (!target) throw new Error(`${configLabel}.targets.${options.targetId} is missing`);
validateTarget(options.targetId, target);
if (options.action === "plan") return plan(options.targetId, target);
if (options.action === "status") return status(options.targetId, target);
if (options.action === "logs") return logs(options.targetId, target, options.tail);
if (options.action === "verify") return await verify(options.targetId, target, options);
if (!options.confirm) return { ...plan(options.targetId, target), mode: "dry-run", mutation: false };
return apply(options.targetId, target);
}
function readConfig(): HyperVConfig {
const raw = record(Bun.YAML.parse(readFileSync(configPath, "utf8")), configLabel);
if (raw.kind !== "platform-infra-hyperv-vms") throw new Error(`${configLabel}.kind must be platform-infra-hyperv-vms`);
const defaults = record(raw.defaults, `${configLabel}.defaults`);
const targets = record(raw.targets, `${configLabel}.targets`) as unknown as Record<string, HyperVTarget>;
return { defaults: { targetId: stringField(defaults, "targetId", `${configLabel}.defaults`) }, targets };
}
function parseOptions(args: string[], defaultTargetId: string): Options {
const action = args[0] ?? "plan";
if (action !== "plan" && action !== "apply" && action !== "status" && action !== "logs" && action !== "verify") {
throw new Error("platform-infra hyperv-vm usage: plan|apply|status|logs|verify --target <id> [--repair-provider-channel --confirm] [--tail 120]");
}
return {
action,
targetId: option(args, "--target") ?? defaultTargetId,
confirm: args.includes("--confirm"),
repairProviderChannel: args.includes("--repair-provider-channel"),
tail: integerOption(args, "--tail", 120, 1, 500),
};
}
function validateTarget(id: string, target: HyperVTarget): void {
if (target.enabled !== true) throw new Error(`${configLabel}.targets.${id}.enabled must be true`);
if (!target.host.route.includes(":win/")) throw new Error(`${configLabel}.targets.${id}.host.route must select a Windows workspace`);
if (target.provider.id !== id) throw new Error(`${configLabel}.targets.${id}.provider.id must equal target id`);
if (target.vm.memoryMinimumBytes <= 0 || target.vm.memoryStartupBytes < target.vm.memoryMinimumBytes || target.vm.memoryMaximumBytes < target.vm.memoryStartupBytes || target.vm.hostReservedMemoryBytes <= 0) {
throw new Error(`${configLabel}.targets.${id}.vm dynamic memory values are invalid`);
}
if (target.vm.diskBytes !== 274_877_906_944) throw new Error(`${configLabel}.targets.${id}.vm.diskBytes must describe the requested 256 GiB disk`);
if (!/^[a-f0-9]{64}$/u.test(target.image.sha256)) throw new Error(`${configLabel}.targets.${id}.image.sha256 must be lowercase SHA-256`);
if (target.image.sources.length < 2) throw new Error(`${configLabel}.targets.${id}.image.sources must include mirror and canonical fallback`);
const k3sManagement = record(target.k3s.management, `${configLabel}.targets.${id}.k3s.management`);
if (k3sManagement.mode !== "external-cluster" && k3sManagement.mode !== "standalone") {
throw new Error(`${configLabel}.targets.${id}.k3s.management.mode must be external-cluster or standalone`);
}
if (typeof k3sManagement.configRef !== "string" || !k3sManagement.configRef.startsWith("config/platform-infra/k3s-clusters.yaml#")) {
throw new Error(`${configLabel}.targets.${id}.k3s.management.configRef must reference the k3s cluster owning YAML`);
}
for (const value of [target.provider.id, target.provider.name, target.provider.composeProject, target.provider.containerName, target.provider.image, target.ubuntu.user, target.k3s.nodeName]) {
if (!/^[A-Za-z0-9._:/-]+$/u.test(value)) throw new Error(`${configLabel}.targets.${id} contains a shell-unsafe provider or user value`);
}
if (!/^[A-Za-z0-9_.@-]+$/u.test(target.provider.systemdUnit)) throw new Error(`${configLabel}.targets.${id}.provider.systemdUnit is invalid`);
for (const [key, value] of Object.entries(target.verification)) {
if (!Number.isInteger(value) || value <= 0) throw new Error(`${configLabel}.targets.${id}.verification.${key} must be a positive integer`);
}
}
function plan(id: string, target: HyperVTarget): Record<string, unknown> {
return {
ok: true,
mutation: false,
targetId: id,
source: configLabel,
host: { route: target.host.route, computerName: target.host.computerName, vmRoot: target.host.vmRoot },
vm: target.vm,
network: target.network,
image: { fileName: target.image.fileName, format: target.image.format, sha256: target.image.sha256, sources: target.image.sources, conversion: target.image.conversion },
runtime: { ubuntu: target.ubuntu, docker: target.docker, k3s: target.k3s, provider: target.provider },
constraints: {
gpu: "unsupported-on-windows-11-hyperv-geforce-linux-guest",
wslDependency: false,
hostMemoryReservedBytes: target.vm.hostReservedMemoryBytes,
guestMemoryMaximumBytes: target.vm.memoryMaximumBytes,
},
next: {
apply: `bun scripts/cli.ts platform-infra hyperv-vm apply --target ${id} --confirm`,
status: `bun scripts/cli.ts platform-infra hyperv-vm status --target ${id}`,
logs: `bun scripts/cli.ts platform-infra hyperv-vm logs --target ${id} --tail 120`,
verify: `bun scripts/cli.ts platform-infra hyperv-vm verify --target ${id}`,
},
};
}
function apply(id: string, target: HyperVTarget): Record<string, unknown> {
const localDir = join(stateRoot, "hyperv-vm", id);
const stagingDir = join(localDir, "bundle");
const generatedConfigPath = join(localDir, "bootstrap-config.json");
const generatedScriptPath = join(localDir, "hyperv-vm-bootstrap.ps1");
const bundlePath = join(localDir, "unidesk-provider-bundle.tar.gz");
const k3sDir = join(localDir, "k3s");
mkdirSync(k3sDir, { recursive: true });
const k3sBinaryPath = join(k3sDir, "k3s");
const k3sInstallerPath = join(k3sDir, "install-k3s.sh");
ensureDownloadedArtifact(String(target.k3s.binaryUrl), String(target.k3s.binarySha256), k3sBinaryPath);
ensureDownloadedArtifact(String(target.k3s.installerUrl), "", k3sInstallerPath);
mkdirSync(localDir, { recursive: true });
rmSync(stagingDir, { recursive: true, force: true });
mkdirSync(stagingDir, { recursive: true });
cpSync(rootPath("src/components/provider-gateway"), join(stagingDir, "src/components/provider-gateway"), { recursive: true });
cpSync(rootPath("src/components/shared"), join(stagingDir, "src/components/shared"), { recursive: true });
const renderedInstaller = renderProviderInstaller(target);
writeFileSync(join(stagingDir, "install-provider.sh"), renderedInstaller, { encoding: "utf8", mode: 0o755 });
const tar = runCommand(["tar", "-czf", bundlePath, "-C", stagingDir, "."], rootPath());
if (tar.exitCode !== 0) throw new Error(`provider bundle creation failed: ${tar.stderr.trim()}`);
const remoteScriptPath = `${target.host.stateDir}\\hyperv-vm-bootstrap.ps1`;
const remoteConfigPath = `${target.host.stateDir}\\bootstrap-config.json`;
const remoteBundlePath = `${target.host.stateDir}\\unidesk-provider-bundle.tar.gz`;
const generated = {
targetId: id,
...target,
artifacts: {
providerBundlePath: remoteBundlePath,
k3sBinaryPath: `${target.host.stateDir}\\k3s`,
k3sInstallerPath: `${target.host.stateDir}\\install-k3s.sh`,
},
};
writeFileSync(generatedConfigPath, `${JSON.stringify(generated, null, 2)}\n`, "utf8");
cpSync(bootstrapScriptPath, generatedScriptPath);
upload(target.host.route, generatedScriptPath, remoteScriptPath, true);
upload(target.host.route, generatedConfigPath, remoteConfigPath, true);
upload(target.host.route, bundlePath, remoteBundlePath, false);
upload(target.host.route, k3sBinaryPath, `${target.host.stateDir}\\k3s`, false);
upload(target.host.route, k3sInstallerPath, `${target.host.stateDir}\\install-k3s.sh`, true);
const launchSource = [
`$script=${psQuote(remoteScriptPath)}`,
`$config=${psQuote(remoteConfigPath)}`,
"$arguments=@('-NoProfile','-ExecutionPolicy','Bypass','-File',$script,'-ConfigPath',$config)",
"$process=Start-Process -FilePath 'powershell.exe' -Verb RunAs -ArgumentList $arguments -PassThru",
`[ordered]@{ok=$true;mutation=$true;state='uac-pending';processId=$process.Id;script=$script;config=$config} | ConvertTo-Json -Compress`,
].join("; ");
const launch = runCommand(["trans", target.host.route, "ps", launchSource], rootPath(), { timeoutMs: 30_000 });
if (launch.exitCode !== 0) throw new Error(`failed to launch elevated Hyper-V bootstrap: ${launch.stderr.trim() || launch.stdout.trim()}`);
return {
ok: true,
mutation: true,
state: "uac-pending",
targetId: id,
message: "Approve the Windows UAC prompt on D601-WIN; installation then continues in the background.",
remote: { scriptPath: remoteScriptPath, configPath: remoteConfigPath, bundlePath: remoteBundlePath },
statusCommand: `bun scripts/cli.ts platform-infra hyperv-vm status --target ${id}`,
launch: parseFirstJson(launch.stdout),
};
}
function ensureDownloadedArtifact(url: string, expectedSha256: string, destination: string): void {
if (existsSync(destination) && (!expectedSha256 || createHash("sha256").update(readFileSync(destination)).digest("hex") === expectedSha256)) return;
const download = runCommand(["curl", "-fsSL", url, "-o", destination], rootPath(), { timeoutMs: 120_000 });
if (download.exitCode !== 0) throw new Error(`k3s artifact download failed: ${url}: ${bounded(download.stderr || download.stdout)}`);
if (expectedSha256) {
const actual = createHash("sha256").update(readFileSync(destination)).digest("hex");
if (actual !== expectedSha256) throw new Error(`k3s artifact hash mismatch: expected=${expectedSha256} actual=${actual}`);
}
}
function status(id: string, target: HyperVTarget): Record<string, unknown> {
const statusPath = `${target.host.stateDir}\\status.json`;
const source = `$p=${psQuote(statusPath)}; if(Test-Path -LiteralPath $p){Get-Content -Raw -LiteralPath $p}else{[ordered]@{ok=$false;state='not-started';stage='none';message='status.json is not present'}|ConvertTo-Json -Compress}`;
const result = runCommand(["trans", target.host.route, "ps", source], rootPath(), { timeoutMs: 30_000 });
if (result.exitCode !== 0) return { ok: false, mutation: false, targetId: id, state: "read-failed", error: bounded(result.stderr || result.stdout) };
const payload = parseFirstJson(result.stdout);
return {
...payload,
mutation: false,
targetId: id,
address: target.network.address,
providerId: target.provider.id,
next: payload.state === "failed"
? { logs: `bun scripts/cli.ts platform-infra hyperv-vm logs --target ${id} --tail 200`, retry: `bun scripts/cli.ts platform-infra hyperv-vm apply --target ${id} --confirm` }
: { status: `bun scripts/cli.ts platform-infra hyperv-vm status --target ${id}` },
};
}
function logs(id: string, target: HyperVTarget, tail: number): Record<string, unknown> {
const logPath = `${target.host.stateDir}\\install.log`;
const source = `$p=${psQuote(logPath)}; if(Test-Path -LiteralPath $p){Get-Content -LiteralPath $p -Tail ${tail}}else{'install.log is not present'}`;
const result = runCommand(["trans", target.host.route, "ps", source], rootPath(), { timeoutMs: 30_000 });
return { ok: result.exitCode === 0, mutation: false, targetId: id, tail, logPath, lines: bounded(result.stdout, 30_000), error: bounded(result.stderr) };
}
async function verify(id: string, target: HyperVTarget, options: Options): Promise<Record<string, unknown>> {
const before = await verifyProviderRoutes(target);
if (before.ok) return {
ok: true,
mutation: false,
targetId: id,
providerId: target.provider.id,
repaired: false,
before,
after: before,
};
const repairCommand = `bun scripts/cli.ts platform-infra hyperv-vm verify --target ${id} --repair-provider-channel --confirm`;
if (!before.repairable) return {
ok: false,
mutation: false,
targetId: id,
providerId: target.provider.id,
repaired: false,
before,
next: { retry: `bun scripts/cli.ts platform-infra hyperv-vm verify --target ${id}` },
};
if (!options.repairProviderChannel || !options.confirm) return {
ok: false,
mutation: false,
targetId: id,
providerId: target.provider.id,
repaired: false,
before,
next: { repair: repairCommand },
};
const repair = await restartProviderChannel(target);
if (!repair.ok) return {
ok: false,
mutation: true,
targetId: id,
providerId: target.provider.id,
repaired: false,
before,
repair,
};
const deadline = Date.now() + target.verification.repairReadyTimeoutSeconds * 1_000;
let after = await verifyProviderRoutes(target);
while (!after.ok && Date.now() < deadline) {
await Bun.sleep(target.verification.repairRetryIntervalSeconds * 1_000);
after = await verifyProviderRoutes(target);
}
return {
ok: after.ok,
mutation: true,
targetId: id,
providerId: target.provider.id,
repaired: after.ok,
before,
repair,
after,
};
}
async function verifyProviderRoutes(target: HyperVTarget): Promise<VerifyRoutesResult> {
const timeoutMs = target.verification.probeTimeoutSeconds * 1_000;
const [host, k3s] = await Promise.all([
runCommandObserved(["trans", target.provider.id, "argv", "hostname"], rootPath(), { timeoutMs, maxCaptureChars: 4_000 }),
runCommandObserved(["trans", `${target.provider.id}:k3s`, "kubectl", "get", "node", target.k3s.nodeName, "-o", "name"], rootPath(), { timeoutMs, maxCaptureChars: 4_000 }),
]);
const failureKind = providerRouteFailureKind(`${host.stdout}\n${host.stderr}\n${k3s.stdout}\n${k3s.stderr}`);
return {
ok: host.exitCode === 0 && k3s.exitCode === 0,
repairable: failureKind === "provider-data-channel-missing",
failureKind,
host: commandEvidence(host),
k3s: commandEvidence(k3s),
};
}
function providerRouteFailureKind(text: string): string | null {
const normalized = text.toLowerCase();
if (normalized.includes("requested ssh tcp data channel is not ready") || normalized.includes("ssh tcp data channel is not available")) return "provider-data-channel-missing";
if (normalized.includes("ssh tcp data channel closed")) return "provider-data-channel-closed";
if (normalized.includes("provider ssh tcp data pool has no idle channel")) return "provider-data-pool-exhausted";
return null;
}
async function restartProviderChannel(target: HyperVTarget): Promise<Record<string, unknown> & { ok: boolean }> {
const keyPath = `${target.host.stateDir}\\id_ed25519`;
const guest = `${target.ubuntu.user}@${target.network.address}`;
const source = [
`$key=${psQuote(keyPath)}`,
`$guest=${psQuote(guest)}`,
`$unit=${psQuote(target.provider.systemdUnit)}`,
"$arguments=@('-i',$key,'-o','BatchMode=yes','-o','ConnectTimeout=8',$guest,'sudo','systemctl','restart',$unit)",
"& ssh.exe @arguments 2>&1",
"$code=$LASTEXITCODE",
"[ordered]@{ok=($code -eq 0);exitCode=$code}|ConvertTo-Json -Compress",
"exit $code",
].join("; ");
const result = await runCommandObserved(["trans", target.host.route, "ps", source], rootPath(), {
timeoutMs: target.verification.probeTimeoutSeconds * 1_000,
maxCaptureChars: 4_000,
});
return { ok: result.exitCode === 0, ...commandEvidence(result) };
}
function commandEvidence(result: { exitCode: number | null; stdout: string; stderr: string; timedOut: boolean }): Record<string, unknown> {
return {
exitCode: result.exitCode,
timedOut: result.timedOut,
stdout: bounded(result.stdout, 1_000),
stderr: bounded(result.stderr, 2_000),
};
}
function renderProviderInstaller(target: HyperVTarget): string {
let source = readFileSync(providerInstallTemplatePath, "utf8");
const replacements: Record<string, string> = {
"@@PROVIDER_ROOT@@": target.provider.root,
"@@STATE_ROOT@@": target.provider.stateRoot,
"@@PROVIDER_ID@@": target.provider.id,
"@@PROVIDER_NAME@@": target.provider.name,
"@@MASTER_SERVER@@": target.provider.masterServer,
"@@COMPOSE_PROJECT@@": target.provider.composeProject,
"@@PROVIDER_IMAGE@@": target.provider.image,
"@@PROVIDER_CONTAINER@@": target.provider.containerName,
"@@UBUNTU_USER@@": target.ubuntu.user,
"@@K3S_NODE_NAME@@": target.k3s.nodeName,
};
for (const [placeholder, value] of Object.entries(replacements)) source = source.replaceAll(placeholder, value);
if (/@@[A-Z0-9_]+@@/u.test(source)) throw new Error("provider installer contains unresolved YAML placeholder");
return source;
}
function upload(route: string, localPath: string, remotePath: string, allowText: boolean): void {
if (!existsSync(localPath)) throw new Error(`upload source is missing: ${localPath}`);
const command = ["trans", route, "upload", localPath, remotePath];
if (allowText) command.push("--allow-text-transfer");
const result = runCommand(command, rootPath(), { timeoutMs: 60_000 });
if (result.exitCode !== 0) throw new Error(`upload failed for ${remotePath}: ${bounded(result.stderr || result.stdout)}`);
}
function parseFirstJson(output: string): Record<string, unknown> {
for (const line of output.split(/\r?\n/u)) {
const trimmed = line.trim();
if (!trimmed.startsWith("{")) continue;
try { return record(JSON.parse(trimmed), "remote JSON"); } catch { /* continue */ }
}
const start = output.indexOf("{");
const end = output.lastIndexOf("}");
if (start >= 0 && end > start) {
try { return record(JSON.parse(output.slice(start, end + 1)), "remote JSON"); } catch { /* fall through */ }
}
throw new Error(`remote command returned no JSON: ${bounded(output)}`);
}
function psQuote(value: string): string {
return `'${value.replaceAll("'", "''")}'`;
}
function option(args: string[], name: string): string | null {
const index = args.indexOf(name);
if (index < 0) return null;
const value = args[index + 1];
if (!value || value.startsWith("--")) throw new Error(`${name} requires a value`);
return value;
}
function integerOption(args: string[], name: string, fallback: number, minimum: number, maximum: number): number {
const raw = option(args, name);
if (raw === null) return fallback;
const value = Number(raw);
if (!Number.isInteger(value) || value < minimum || value > maximum) throw new Error(`${name} must be ${minimum}..${maximum}`);
return value;
}
function record(value: unknown, label: string): Record<string, any> {
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${label} must be an object`);
return value as Record<string, any>;
}
function stringField(value: Record<string, unknown>, key: string, label: string): string {
const field = value[key];
if (typeof field !== "string" || field.length === 0) throw new Error(`${label}.${key} must be a non-empty string`);
return field;
}
function bounded(value: string, max = 4_000): string {
const text = value.trim();
return text.length <= max ? text : `${text.slice(0, max)}\n...[truncated ${text.length - max} chars]`;
}