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
371 lines
14 KiB
Python
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")
|