Files
wehub-resource-sync 91e75e620b
CI: cua-driver distro-compat matrix / debian:12 (glibc 2.36) (push) Has been cancelled
CI: SPDX Headers / Check SPDX headers (warn-only) (push) Has been cancelled
CD: Docs MCP Server / build (linux/amd64) (push) Has been cancelled
CD: Docs MCP Server / build (linux/arm64) (push) Has been cancelled
CD: Docs MCP Server / merge (push) Has been cancelled
CI: cua-driver distro-compat matrix / Resolve release version (push) Has been cancelled
CI: cua-driver distro-compat matrix / fedora:41 (glibc 2.40) (push) Has been cancelled
CI: cua-driver distro-compat matrix / rockylinux:9 (glibc 2.34) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:22.04 (glibc 2.35) (push) Has been cancelled
CI: cua-driver distro-compat matrix / ubuntu:24.04 (glibc 2.39) (push) Has been cancelled
CI: cua-driver distro-compat matrix / Distro compat summary (push) Has been cancelled
CI: Rust Linux unit / Rust Linux unit and compile (push) Has been cancelled
CI: Rust Windows unit / Rust Windows unit and compile (push) Has been cancelled
CI: Nix Linux Rust source / Nix / compositor build (push) Has been cancelled
CI: Nix Linux Rust source / Nix / driver package (push) Has been cancelled
CI: Nix Linux Rust source / Nix / Rust unit tests (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:03:19 +08:00

371 lines
14 KiB
Python

"""Hyper-V runtime — Windows VMs built from ISO via unattended install.
Builds a Windows image from scratch using Hyper-V Gen2 VMs (hardware-accelerated),
with the same Autounattend.xml + CUA server install as the QEMU builder.
Layer chain (all VHDX, cached):
Layer 0 (base): base-windows-{ver}.vhdx — Windows + CUA server (built from ISO)
Layer 1 (user): user-{hash}.vhdx — user's .winget_install()/.run() etc
Layer 2 (session): session-{name}.vhdx — ephemeral per-sandbox run
Requires Windows Pro/Enterprise/Education with Hyper-V enabled.
"""
from __future__ import annotations
import asyncio
import hashlib
import json
import logging
import subprocess
from pathlib import Path
from typing import Optional
import httpx
from cua_sandbox.builder.windows_unattend import (
create_unattend_iso,
download_windows_iso,
)
from cua_sandbox.image import Image
from cua_sandbox.runtime.base import Runtime, RuntimeInfo
from cua_sandbox.runtime.images import DEFAULT_API_PORT
logger = logging.getLogger(__name__)
CACHE_DIR = Path.home() / ".cua" / "cua-sandbox" / "hyperv"
def _has_hyperv() -> bool:
try:
result = subprocess.run(
["powershell", "-Command", "Get-Command New-VM -ErrorAction Stop"],
capture_output=True,
text=True,
)
return result.returncode == 0
except (subprocess.SubprocessError, FileNotFoundError):
return False
def _ps(cmd: str) -> str:
"""Run a PowerShell command and return stdout. Raises on failure."""
result = subprocess.run(
["powershell", "-NoProfile", "-Command", cmd],
capture_output=True,
text=True,
)
if result.returncode != 0:
raise RuntimeError(f"PowerShell error: {result.stderr.strip()}")
return result.stdout.strip()
def _create_diff_vhdx(parent: Path, child: Path) -> Path:
"""Create a differencing VHDX with the given parent."""
child.parent.mkdir(parents=True, exist_ok=True)
_ps(f"New-VHD -ParentPath '{parent}' -Path '{child}' -Differencing")
logger.info(f"Created differencing VHDX: {child} (parent: {parent})")
return child
class HyperVRuntime(Runtime):
"""Runs Windows VMs via Hyper-V with layered VHDX caching.
On first use, builds a base Windows image from ISO using unattended
install (same Autounattend.xml as the QEMU builder). The base image
includes CUA computer-server pre-installed. This is cached and reused.
Each sandbox session gets its own ephemeral differencing disk on top.
"""
def __init__(
self,
*,
api_port: int = DEFAULT_API_PORT,
switch_name: str = "Default Switch",
memory_mb: int = 4096,
cpu_count: int = 4,
disk_size_gb: int = 64,
cache_dir: Optional[Path] = None,
windows_iso: Optional[str] = None,
product_key: Optional[str] = None,
):
self.api_port = api_port
self.switch_name = switch_name
self.memory_mb = memory_mb
self.cpu_count = cpu_count
self.disk_size_gb = disk_size_gb
self.cache_dir = cache_dir or CACHE_DIR
self.windows_iso = windows_iso
self.product_key = product_key
async def start(self, image: Image, name: str, **opts) -> RuntimeInfo:
if not _has_hyperv():
raise RuntimeError(
"Hyper-V is not available. Enable it via: "
"Enable-WindowsOptionalFeature -Online -FeatureName Microsoft-Hyper-V -All"
)
memory = opts.get("memory_mb", self.memory_mb)
cpus = opts.get("cpu_count", self.cpu_count)
switch = opts.get("switch_name", self.switch_name)
# Build the layered VHDX chain
session_disk = await self._resolve_session_disk(image, name)
# Clean up any existing VM with this name
self._cleanup_vm(name)
logger.info(f"Creating Hyper-V VM {name} from {session_disk}")
_ps(
f"New-VM -Name '{name}' "
f"-MemoryStartupBytes {memory * 1024 * 1024} "
f"-VHDPath '{session_disk}' "
f"-SwitchName '{switch}' "
f"-Generation 2"
)
_ps(f"Set-VM -Name '{name}' -ProcessorCount {cpus}")
_ps(f"Set-VMFirmware -VMName '{name}' -EnableSecureBoot Off")
_ps(f"Start-VM -Name '{name}'")
ip = await self._wait_for_ip(name)
info = RuntimeInfo(host=ip, api_port=self.api_port, name=name)
await self.is_ready(info)
return info
async def _resolve_session_disk(self, image: Image, name: str) -> Path:
"""Build the VHDX layer chain and return the session disk path.
Layer 0: base-windows-{ver}.vhdx (Windows + CUA server, cached)
Layer 1: user-{hash}.vhdx (user layers applied, cached) — only if image has layers
Layer 2: session-{name}.vhdx (ephemeral)
"""
self.cache_dir.mkdir(parents=True, exist_ok=True)
version = image.version or "11"
# Layer 0: Base image (Windows + CUA server installed from ISO)
base = await self._ensure_base_image(version)
# Layer 1: User layers (cached, if any)
if image._layers:
layer_hash = hashlib.sha256(
json.dumps(list(image._layers), sort_keys=True).encode()
).hexdigest()[:16]
user_layer = self.cache_dir / f"user-{layer_hash}.vhdx"
if not user_layer.exists():
user_layer = await self._build_user_layer(image, base, user_layer)
backing = user_layer
else:
backing = base
# Layer 2: Ephemeral session disk
sessions_dir = self.cache_dir / "sessions"
session_disk = sessions_dir / f"{name}.vhdx"
if session_disk.exists():
session_disk.unlink()
_create_diff_vhdx(backing, session_disk)
return session_disk
async def _ensure_base_image(self, version: str = "11") -> Path:
"""Build or return cached base Windows image with CUA server installed."""
base_vhdx = self.cache_dir / f"base-windows-{version}.vhdx"
if base_vhdx.exists():
logger.info(f"Using cached base image: {base_vhdx}")
return base_vhdx
logger.info(
"Building base Windows image (first-time setup, ~15-30 min with Hyper-V acceleration)..."
)
work_dir = self.cache_dir / "build"
work_dir.mkdir(parents=True, exist_ok=True)
# Get Windows ISO
win_iso = download_windows_iso(version, work_dir, self.windows_iso)
# Create unattend ISO (no virtio drivers needed, no startup.nsh needed)
unattend_iso = create_unattend_iso(
work_dir,
self.product_key,
include_virtio_drivers=False,
include_startup_nsh=False,
)
# Create VHDX disk
build_vhdx = work_dir / "build-disk.vhdx"
if build_vhdx.exists():
build_vhdx.unlink()
_ps(f"New-VHD -Path '{build_vhdx}' -SizeBytes {self.disk_size_gb}GB -Dynamic")
vm_name = "cua-hyperv-build"
self._cleanup_vm(vm_name)
try:
# Create Gen2 VM
_ps(
f"New-VM -Name '{vm_name}' "
f"-MemoryStartupBytes {self.memory_mb * 1024 * 1024} "
f"-VHDPath '{build_vhdx}' "
f"-SwitchName '{self.switch_name}' "
f"-Generation 2"
)
_ps(f"Set-VM -Name '{vm_name}' -ProcessorCount {self.cpu_count}")
_ps(f"Set-VMFirmware -VMName '{vm_name}' -EnableSecureBoot Off")
# Attach Windows ISO as DVD
_ps(f"Add-VMDvdDrive -VMName '{vm_name}' -Path '{win_iso}'")
# Attach unattend ISO as second DVD
_ps(f"Add-VMDvdDrive -VMName '{vm_name}' -Path '{unattend_iso}'")
# Set boot order: DVD first (Windows installer), then hard drive
_ps(
f"$dvd = Get-VMDvdDrive -VMName '{vm_name}' | Where-Object {{ $_.Path -eq '{win_iso}' }}; "
f"Set-VMFirmware -VMName '{vm_name}' -FirstBootDevice $dvd"
)
# Start the VM — Windows Setup finds Autounattend.xml automatically
logger.info("Starting Windows unattended install via Hyper-V...")
_ps(f"Start-VM -Name '{vm_name}'")
# Wait for CUA server to come up (Windows install + first login + setup script)
ip = await self._wait_for_ip(vm_name, timeout=600)
logger.info(f"Build VM got IP: {ip}")
info = RuntimeInfo(host=ip, api_port=self.api_port, name=vm_name)
await self.is_ready(info, timeout=2700) # 45 min max
logger.info("CUA computer-server installed and verified!")
# Shut down cleanly
_ps(f"Stop-VM -Name '{vm_name}' -Force")
await asyncio.sleep(5)
finally:
# Remove DVD drives and VM definition (keep the disk)
try:
_ps(f"Get-VMDvdDrive -VMName '{vm_name}' | Remove-VMDvdDrive")
except RuntimeError:
pass
try:
_ps(f"Remove-VM -Name '{vm_name}' -Force")
except RuntimeError:
pass
# Move build disk to final cached location
build_vhdx.rename(base_vhdx)
logger.info(f"Base image cached: {base_vhdx}")
return base_vhdx
async def _build_user_layer(self, image: Image, parent: Path, user_layer: Path) -> Path:
"""Apply user Image layers on top of the base image."""
logger.info(f"Building user layer ({len(image._layers)} layers)...")
build_disk = user_layer.with_suffix(".build.vhdx")
if build_disk.exists():
build_disk.unlink()
_create_diff_vhdx(parent, build_disk)
vm_name = "cua-hyperv-build-user"
self._cleanup_vm(vm_name)
_ps(
f"New-VM -Name '{vm_name}' "
f"-MemoryStartupBytes {self.memory_mb * 1024 * 1024} "
f"-VHDPath '{build_disk}' "
f"-SwitchName '{self.switch_name}' "
f"-Generation 2"
)
_ps(f"Set-VM -Name '{vm_name}' -ProcessorCount {self.cpu_count}")
_ps(f"Set-VMFirmware -VMName '{vm_name}' -EnableSecureBoot Off")
_ps(f"Start-VM -Name '{vm_name}'")
try:
ip = await self._wait_for_ip(vm_name, timeout=300)
info = RuntimeInfo(host=ip, api_port=self.api_port, name=vm_name)
await self.is_ready(info, timeout=300)
from cua_sandbox.builder.executor import LayerExecutor
executor = LayerExecutor(f"http://{ip}:{self.api_port}")
await executor.execute_layers(list(image._layers))
logger.info("User layers applied, shutting down build VM...")
_ps(f"Stop-VM -Name '{vm_name}' -Force")
await asyncio.sleep(5)
finally:
self._cleanup_vm(vm_name)
build_disk.rename(user_layer)
meta = user_layer.with_suffix(".json")
meta.write_text(
json.dumps(
{
"os_type": image.os_type,
"layers": list(image._layers),
"parent": str(parent),
},
indent=2,
)
)
logger.info(f"User layer cached: {user_layer}")
return user_layer
async def _wait_for_ip(self, name: str, timeout: float = 180) -> str:
deadline = asyncio.get_event_loop().time() + timeout
while asyncio.get_event_loop().time() < deadline:
try:
ip = _ps(
f"(Get-VM -Name '{name}' | Get-VMNetworkAdapter).IPAddresses "
f"| Where-Object {{ $_ -match '\\d+\\.\\d+\\.\\d+\\.\\d+' }} "
f"| Select-Object -First 1"
)
if ip and not ip.startswith("0.0.0.0"):
return ip
except RuntimeError:
pass
await asyncio.sleep(3)
raise TimeoutError(f"Hyper-V VM {name} did not get an IP within {timeout}s")
def _cleanup_vm(self, name: str) -> None:
"""Stop and remove a VM if it exists."""
try:
exists = _ps(
f"Get-VM -Name '{name}' -ErrorAction SilentlyContinue "
f"| Select-Object -ExpandProperty Name"
)
if exists == name:
_ps(f"Stop-VM -Name '{name}' -Force -ErrorAction SilentlyContinue")
_ps(f"Remove-VM -Name '{name}' -Force")
except RuntimeError:
pass
async def stop(self, name: str) -> None:
"""Stop the VM, remove it, and delete the ephemeral session disk."""
self._cleanup_vm(name)
session_disk = self.cache_dir / "sessions" / f"{name}.vhdx"
if session_disk.exists():
session_disk.unlink()
logger.info(f"Deleted session disk: {session_disk}")
async def is_ready(self, info: RuntimeInfo, timeout: float = 120) -> bool:
url = f"http://{info.host}:{info.api_port}/status"
deadline = asyncio.get_event_loop().time() + timeout
async with httpx.AsyncClient(timeout=5) as client:
while asyncio.get_event_loop().time() < deadline:
try:
resp = await client.get(url)
if resp.status_code == 200:
logger.info(f"Hyper-V VM {info.name} computer-server is ready")
return True
except (
httpx.ConnectError,
httpx.ReadTimeout,
httpx.RemoteProtocolError,
httpx.ConnectTimeout,
httpx.ReadError,
):
pass
await asyncio.sleep(3)
raise TimeoutError(f"Hyper-V VM {info.name} computer-server not ready after {timeout}s")