"""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")