From 6e18c03c1bbe24d14e6b77c5f5b13a5c5578b20b Mon Sep 17 00:00:00 2001 From: Robert Wendt Date: Wed, 12 Aug 2026 18:17:39 +0000 Subject: [PATCH] feat(sandbox)!: boot the same Windows containerDisk locally and in Fleet cloud MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BREAKING CHANGE: two Windows defaults move from "11" to "2022". `Image.windows()` now defaults to `version="2022"` (Windows Server 2022), and cua-cli's bare `windows` image alias follows it. Callers of either bare default previously got a Windows 11 evaluation-ISO install locally and NotImplementedError on Fleet cloud; they now get the pinned Server 2022 containerDisk, which works on both paths. `Image.windows("11")` and `windows:11` still mean client Windows 11 and are unchanged. The Linux row is repinned to public.ecr.aws/k5j5w0x5/cua-ubuntu-24.04 to match bit-identical, so this is a string change; Linux was not re-tested here. Three user-facing docs that still pointed at private ECR were moved to the public refs as well. `Image.windows(...)` had no registry counterpart, so the Fleet cloud path rejected it and the local QEMU path fell through to `_build_windows_base`, which downloads a ~6 GB Windows ISO and runs an unattended install. Both paths now resolve the same pinned KubeVirt containerDisk, so a local run and a cloud run boot identical bytes. Generalises `cloud_registry_image` into a `BUILTIN_REGISTRY_IMAGES` descriptor table keyed on (os_type, distro, version, kind), replacing the single hardcoded Linux branch. Linux keeps its existing pin; Windows Server 2022 is the new row. The image is `public.ecr.aws/k5j5w0x5/cua-windows-2022:main-bac7daa3` — an anonymously pullable mirror, so the built-in Windows image needs no registry credentials. It is an OCI index whose children are the linux/amd64 containerDisk and a buildx provenance attestation. Selecting an oras auth backend now asks the registry instead of guessing. Private ECR challenges with Basic, where oras' token backend raises "Cannot respond to request for authentication"; public.ecr.aws and ghcr.io challenge with Bearer, where basic auth has no credential to send. Since the pinned Linux image is on private ECR and the pinned Windows image is on public ECR, no fixed backend works for both, so `_detect_auth_backend` reads the scheme off the registry's /v2/ endpoint. Also fixes UEFI firmware discovery, without which the local Windows path cannot boot on current Ubuntu. The bare-metal runtime looked only for `/usr/share/OVMF/OVMF_CODE.fd` (Ubuntu 24.04 ships `OVMF_CODE_4M.fd`), so it found no firmware and added no pflash drives. The WSL-hosted runtime had a narrower defect: it did try the 4M file first, but chose the code file and the varstore in two independent loops, so a host with 2M code and 4M vars got a mismatched pair, and with no vars present at all it fabricated a zero-filled varstore valid at neither size. Both now take code and vars from the same candidate entry. Verifying that on a real Windows host surfaced a second WSL bug: the session overlay is created by the Windows-side builder, so the backing path recorded in the qcow2 is a Windows path, and QEMU inside WSL parsed the drive letter as a URI scheme -- "Could not open backing file: Unknown protocol 'C'". It now repoints the overlay with a metadata-only `qemu-img rebase -u`. This affected any layered or base-image disk on WSL; it was previously unreachable because Image.windows() had no disk to overlay. Windows examples that mean "give me a Windows sandbox" move to the bare `Image.windows()`: the two sandbox_sdk integration examples, the CLI's MCP `create_sandbox` tool, the CLI's bare `windows` alias, and the images guide. `tests/test_runtime.py` deliberately stays on `Image.windows("11")` — it is the remaining coverage of the Windows 11 ISO-install path. Verified end to end on a bare-metal host with KVM, from a clean HOME with no docker credentials present: [e2e] docker config = /home/ubuntu/e2e-home/.docker/config.json exists=False [e2e] image = Image(windows/windows:2022, kind=vm, 0 layers) [e2e] default windows() = public.ecr.aws/k5j5w0x5/cua-windows-2022:main-bac7daa3 [e2e] chosen auth = token Cached containerDisk public.ecr.aws/k5j5w0x5/cua-windows-2022:main-bac7daa3 Created overlay: .../sessions/e2e-windows-public.qcow2 Bare-metal QEMU VM e2e-windows-public is ready [e2e] pull + boot in 446s $ ver -> Microsoft Windows [Version 10.0.20348.587] $ hostname -> DOCKERW-K5E4442 screenshot: 170369 bytes, PNG Co-Authored-By: Claude Opus 5 (1M context) --- .../docs/how-to-guides/sandbox/images.mdx | 3 +- libs/python/cua-cli/cua_cli/commands/mcp.py | 2 +- .../cua-cli/cua_cli/commands/sandbox.py | 3 +- libs/python/cua-sandbox/cua_sandbox/image.py | 39 +++-- .../cua_sandbox/registry/__init__.py | 2 + .../cua-sandbox/cua_sandbox/runtime/qemu.py | 140 ++++++++++++------ .../cua-sandbox/tests/test_base_disk.py | 102 +++++++++++++ .../tests/test_fleet_cloud_transport.py | 16 +- libs/python/cua-sandbox/tests/test_image.py | 59 ++++++-- .../python/cua-sandbox/tests/test_qemu_wsl.py | 75 ++++++++++ .../cua-sandbox/tests/test_uefi_firmware.py | 75 ++++++++++ .../sandbox_sdk/test_windows_cloud_vm.py | 13 +- .../sandbox_sdk/test_windows_local_vm.py | 13 +- 13 files changed, 462 insertions(+), 80 deletions(-) create mode 100644 libs/python/cua-sandbox/tests/test_base_disk.py create mode 100644 libs/python/cua-sandbox/tests/test_qemu_wsl.py create mode 100644 libs/python/cua-sandbox/tests/test_uefi_firmware.py diff --git a/docs/content/docs/how-to-guides/sandbox/images.mdx b/docs/content/docs/how-to-guides/sandbox/images.mdx index 39ea45d72d..e797e641a0 100644 --- a/docs/content/docs/how-to-guides/sandbox/images.mdx +++ b/docs/content/docs/how-to-guides/sandbox/images.mdx @@ -21,7 +21,8 @@ Image.linux(kind='container') # Ubuntu 24.04 container (lighter, Docker/X Image.linux('ubuntu', '22.04') # Older Ubuntu Image.macos() # macOS Tahoe (version '26', latest) Image.macos('15') # macOS Sequoia -Image.windows() # Windows 11 +Image.windows() # Windows Server 2022 +Image.windows('11') # Windows 11 (local ISO install only) Image.android() # Android 14 ``` diff --git a/libs/python/cua-cli/cua_cli/commands/mcp.py b/libs/python/cua-cli/cua_cli/commands/mcp.py index 404b0c63d7..395ecced44 100644 --- a/libs/python/cua-cli/cua_cli/commands/mcp.py +++ b/libs/python/cua-cli/cua_cli/commands/mcp.py @@ -244,7 +244,7 @@ async def sandbox_create( if os_type == "macos": image = Image.macos("26") elif os_type == "windows": - image = Image.windows("11") + image = Image.windows() else: image = Image.linux("ubuntu", "24.04") sb = await Sandbox.create(image, api_key=await get_access_token(), region=region) diff --git a/libs/python/cua-cli/cua_cli/commands/sandbox.py b/libs/python/cua-cli/cua_cli/commands/sandbox.py index e0228314c1..a035acc4ec 100644 --- a/libs/python/cua-cli/cua_cli/commands/sandbox.py +++ b/libs/python/cua-cli/cua_cli/commands/sandbox.py @@ -49,6 +49,7 @@ def _parse_image(image_str: str, vm: bool = False): "macos:sequoia" -> Image.macos("sequoia") "ubuntu:24.04" -> Image.linux("ubuntu", "24.04") "linux" -> Image.linux("ubuntu", "24.04") + "windows" -> Image.windows("2022") "windows:11" -> Image.windows("11") "android:14" -> Image.android("14") "ghcr.io/org/img" -> Image.from_registry("ghcr.io/org/img") @@ -80,7 +81,7 @@ def _parse_image(image_str: str, vm: bool = False): return Image.linux(distro, version, kind=kind) if base in _WINDOWS_ALIASES: - version = tag or "11" + version = tag or "2022" return Image.windows(version) if base in _ANDROID_ALIASES: diff --git a/libs/python/cua-sandbox/cua_sandbox/image.py b/libs/python/cua-sandbox/cua_sandbox/image.py index e66520bca7..acc8ec589f 100644 --- a/libs/python/cua-sandbox/cua_sandbox/image.py +++ b/libs/python/cua-sandbox/cua_sandbox/image.py @@ -29,6 +29,19 @@ logger = logging.getLogger(__name__) DEFAULT_LINUX_REGISTRY_IMAGE = "public.ecr.aws/k5j5w0x5/cua-ubuntu-24.04:main-38352d34" +# Anonymously pullable, so the built-in Windows image needs no registry credentials. +# The guest is Windows Server 2022 (build 10.0.20348), which is why ``Image.windows()`` +# defaults to "2022". ``Image.windows("11")`` still means client Windows 11, which has +# no containerDisk and is installed locally from a downloaded evaluation ISO. +# Index digest: sha256:6d341afc26a37c4072d22ba403a89ecdad9a29aebab79570b5a38da6b8e16370 +DEFAULT_WINDOWS_REGISTRY_IMAGE = "public.ecr.aws/k5j5w0x5/cua-windows-2022:main-bac7daa3" + +# Built-in image descriptors that resolve to a pinned KubeVirt containerDisk, so +# Fleet cloud and the local QEMU runtime boot byte-identical disks. +BUILTIN_REGISTRY_IMAGES: Dict[Tuple[str, str, str, Optional[str]], str] = { + ("linux", "ubuntu", "24.04", "vm"): DEFAULT_LINUX_REGISTRY_IMAGE, + ("windows", "windows", "2022", "vm"): DEFAULT_WINDOWS_REGISTRY_IMAGE, +} _IMAGE_CACHE = Path.home() / ".cua" / "cua-sandbox" / "image-cache" @@ -145,8 +158,14 @@ def macos(cls, version: str = "26", kind: str = "vm") -> Image: return cls(os_type="macos", distro="macos", version=version, kind=kind) @classmethod - def windows(cls, version: str = "11", kind: str = "vm") -> Image: - """Windows image. Always a VM (QEMU or Hyper-V).""" + def windows(cls, version: str = "2022", kind: str = "vm") -> Image: + """Windows image. Always a VM (QEMU or Hyper-V). + + Defaults to ``"2022"`` (Windows Server 2022), the only version with a pinned + containerDisk — see :data:`BUILTIN_REGISTRY_IMAGES`. Other versions, including + ``"11"``, have no pinned disk: on Fleet cloud they are unsupported, and locally + they are installed from a downloaded evaluation ISO. + """ return cls(os_type="windows", distro="windows", version=version, kind=kind) @classmethod @@ -482,14 +501,12 @@ def __repr__(self) -> str: def cloud_registry_image(image: Image) -> Optional[str]: - """Return the explicit or built-in registry image used by Fleet cloud.""" + """Return the explicit or built-in containerDisk reference for an image. + + An explicit ``Image.from_registry(...)`` reference always wins; otherwise the + built-in descriptors resolve through :data:`BUILTIN_REGISTRY_IMAGES`. Images + with no pinned disk (custom distros, container kinds) return ``None``. + """ if image._registry is not None: return image._registry - if ( - image.os_type == "linux" - and image.distro == "ubuntu" - and image.version == "24.04" - and image.kind == "vm" - ): - return DEFAULT_LINUX_REGISTRY_IMAGE - return None + return BUILTIN_REGISTRY_IMAGES.get((image.os_type, image.distro, image.version, image.kind)) diff --git a/libs/python/cua-sandbox/cua_sandbox/registry/__init__.py b/libs/python/cua-sandbox/cua_sandbox/registry/__init__.py index 9ea63bd7a0..bc32beabf1 100644 --- a/libs/python/cua-sandbox/cua_sandbox/registry/__init__.py +++ b/libs/python/cua-sandbox/cua_sandbox/registry/__init__.py @@ -1,4 +1,5 @@ from cua_sandbox.registry.cache import ImageCache +from cua_sandbox.registry.container_disk import pull_container_disk from cua_sandbox.registry.manifest import ( ImageFormat, detect_format, @@ -21,6 +22,7 @@ "resolve_image_kind", "pull_image", "ImageCache", + "pull_container_disk", "QEMUImageConfig", "push_qemu_image", "pull_qemu_image", diff --git a/libs/python/cua-sandbox/cua_sandbox/runtime/qemu.py b/libs/python/cua-sandbox/cua_sandbox/runtime/qemu.py index d081c83cd1..7357b34613 100644 --- a/libs/python/cua-sandbox/cua_sandbox/runtime/qemu.py +++ b/libs/python/cua-sandbox/cua_sandbox/runtime/qemu.py @@ -11,8 +11,10 @@ import json as _json import logging import platform as _plat +import re import shutil import subprocess +from collections.abc import Callable from pathlib import Path from typing import TYPE_CHECKING, Optional @@ -152,6 +154,42 @@ async def resume(self, image: "Image", name: str, **opts) -> RuntimeInfo: return info +# UEFI firmware for Windows guests, as (code, vars-template) pairs. The two halves +# are sized to match each other, so they are always taken from the same entry — a +# 4 MB OVMF build paired with a 2 MB varstore leaves the guest unable to boot. +# Paths are relative to the bundled QEMU directory, or absolute for system installs. +_UEFI_FIRMWARE_CANDIDATES: list[tuple[str, str]] = [ + ("share/edk2-x86_64-code.fd", "share/edk2-i386-vars.fd"), + ("/usr/share/OVMF/OVMF_CODE_4M.fd", "/usr/share/OVMF/OVMF_VARS_4M.fd"), + ("/usr/share/OVMF/OVMF_CODE.fd", "/usr/share/OVMF/OVMF_VARS.fd"), + ("/usr/share/qemu/edk2-x86_64-code.fd", "/usr/share/qemu/edk2-i386-vars.fd"), +] + + +def _locate_uefi_firmware(qemu_dir: Path) -> tuple[Optional[Path], Optional[Path]]: + """Return the first (OVMF code, matching vars template) pair present on this host.""" + for code_name, vars_name in _UEFI_FIRMWARE_CANDIDATES: + code = Path(code_name) if code_name.startswith("/") else qemu_dir / code_name + if not code.exists(): + continue + template = Path(vars_name) if vars_name.startswith("/") else qemu_dir / vars_name + return code, template if template.exists() else None + return None, None + + +def _locate_uefi_firmware_in(exists: Callable[[str], bool]) -> tuple[Optional[str], Optional[str]]: + """Same lookup as :func:`_locate_uefi_firmware`, for a guest filesystem like WSL. + + Only the absolute candidates apply — a WSL distro has its own /usr/share, and the + Windows-side bundled QEMU directory is not on its path. + """ + for code_name, vars_name in _UEFI_FIRMWARE_CANDIDATES: + if not code_name.startswith("/") or not exists(code_name): + continue + return code_name, vars_name if exists(vars_name) else None + return None, None + + class QEMUBaremetalRuntime(Runtime): """Bare-metal QEMU — launches qemu-system-* directly on the host. @@ -258,24 +296,16 @@ async def start(self, image: Image, name: str, **opts) -> RuntimeInfo: # Locate OVMF UEFI firmware for Windows VMs qemu_dir = Path(self._qemu_bin()).parent - ovmf_code = None - if image.os_type == "windows": - for candidate in [ - qemu_dir / "share" / "edk2-x86_64-code.fd", - Path("/usr/share/OVMF/OVMF_CODE.fd"), - Path("/usr/share/qemu/edk2-x86_64-code.fd"), - ]: - if candidate.exists(): - ovmf_code = candidate - break - - # EFI vars — look next to disk or copy template + ovmf_code, vars_template = ( + _locate_uefi_firmware(qemu_dir) if image.os_type == "windows" else (None, None) + ) + + # EFI vars — look next to disk or copy the template that matches the firmware efivars = Path(disk_path).parent / "efivars.fd" if ovmf_code and not efivars.exists(): import shutil as _shutil - vars_template = qemu_dir / "share" / "edk2-i386-vars.fd" - if vars_template.exists(): + if vars_template is not None: _shutil.copy2(vars_template, efivars) else: efivars.write_bytes(b"\x00" * (256 * 1024)) @@ -769,6 +799,11 @@ async def _is_ready_qmp(self, info: RuntimeInfo, timeout: float = 120) -> bool: raise TimeoutError(f"Bare-metal QEMU VM {info.name} QMP not ready after {timeout}s") +# A drive-letter path such as C:\\Users\\... or C:/Users/..., which QEMU running +# inside WSL would otherwise parse as a URI with scheme "C". +_WINDOWS_DRIVE_PATH = re.compile(r"^[A-Za-z]:[\\/]") + + def _win_to_wsl(p: Path | str) -> str: """Convert a Windows path to WSL /mnt/... path.""" s = str(p).replace("\\", "/") @@ -820,6 +855,40 @@ def _wsl(cmd: str, timeout: float = 30) -> str: raise RuntimeError(f"WSL command failed: {r.stderr.strip()}") return r.stdout.strip() + def _wsl_file_exists(self, path: str) -> bool: + """True when `path` is a regular file inside the WSL distribution.""" + try: + self._wsl(f"test -f {path}") + return True + except (RuntimeError, subprocess.SubprocessError, OSError): + return False + + def _rebase_backing_file(self, wsl_disk: str) -> None: + """Rewrite a session overlay's backing path into WSL's view of the filesystem. + + The overlay is created by the Windows-side builder, so its recorded backing + file is a Windows path. QEMU inside WSL reads the drive letter as a URI scheme + and refuses the drive with ``Could not open backing file: Unknown protocol 'C'``. + The rebase is metadata-only (``-u``): it repoints the overlay without touching + a byte of either image. + """ + try: + info = _json.loads(self._wsl(f"qemu-img info --output=json '{wsl_disk}'")) + except (RuntimeError, ValueError) as exc: + logger.debug("Could not inspect %s for a backing file: %s", wsl_disk, exc) + return + + backing = info.get("backing-filename") + if not backing or not _WINDOWS_DRIVE_PATH.match(backing): + return + + wsl_backing = _win_to_wsl(backing) + logger.info("Rebasing %s onto its WSL backing path %s", wsl_disk, wsl_backing) + self._wsl( + f"qemu-img rebase -u -f qcow2 -F qcow2 -b '{wsl_backing}' '{wsl_disk}'", + timeout=120, + ) + @staticmethod def available() -> bool: """Check if WSL2 + QEMU + KVM are available.""" @@ -864,44 +933,31 @@ async def start(self, image: Image, name: str, **opts) -> RuntimeInfo: # Convert Windows paths to WSL paths wsl_disk = _win_to_wsl(disk_path) + self._rebase_backing_file(wsl_disk) disk_ext = Path(disk_path).suffix.lower() disk_fmt = {".qcow2": "qcow2", ".vhdx": "vhdx", ".raw": "raw", ".img": "raw"}.get( disk_ext, "raw" ) - # Locate OVMF inside WSL - ovmf_code = None - if image.os_type == "windows": - for candidate in [ - "/usr/share/OVMF/OVMF_CODE_4M.fd", - "/usr/share/OVMF/OVMF_CODE.fd", - "/usr/share/qemu/edk2-x86_64-code.fd", - ]: - try: - self._wsl(f"test -f {candidate}") - ovmf_code = candidate - break - except RuntimeError: - pass + # Locate OVMF inside WSL — code and vars must come from the same entry, or a + # 4 MB firmware ends up backed by a 2 MB varstore and the guest cannot boot. + ovmf_code, vars_template = ( + _locate_uefi_firmware_in(self._wsl_file_exists) + if image.os_type == "windows" + else (None, None) + ) # EFI vars — create in same dir as disk (WSL path) efivars_win = Path(disk_path).parent / "efivars.fd" wsl_efivars = _win_to_wsl(efivars_win) if ovmf_code and not efivars_win.exists(): - # Copy OVMF vars template via WSL - for vars_candidate in [ - "/usr/share/OVMF/OVMF_VARS_4M.fd", - "/usr/share/OVMF/OVMF_VARS.fd", - "/usr/share/qemu/edk2-i386-vars.fd", - ]: - try: - self._wsl(f"test -f {vars_candidate} && cp {vars_candidate} '{wsl_efivars}'") - break - except RuntimeError: - continue - else: - # Create empty vars file - efivars_win.write_bytes(b"\x00" * (256 * 1024)) + if vars_template is None: + raise RuntimeError( + f"WSL has UEFI firmware at {ovmf_code} but no matching variable store. " + "Install the OVMF package inside the WSL distribution " + "(apt install ovmf) so the two halves match." + ) + self._wsl(f"cp {vars_template} '{wsl_efivars}'") # Build QEMU command (runs inside WSL) parts = [ diff --git a/libs/python/cua-sandbox/tests/test_base_disk.py b/libs/python/cua-sandbox/tests/test_base_disk.py new file mode 100644 index 0000000000..f1d34a02af --- /dev/null +++ b/libs/python/cua-sandbox/tests/test_base_disk.py @@ -0,0 +1,102 @@ +"""The local QEMU path resolves built-in images to the same pinned containerDisk.""" + +from pathlib import Path + +import pytest +from cua_sandbox.builder import build +from cua_sandbox.image import ( + DEFAULT_LINUX_REGISTRY_IMAGE, + DEFAULT_WINDOWS_REGISTRY_IMAGE, + Image, +) + + +@pytest.fixture +def pulled(monkeypatch, tmp_path): + """Capture the refs handed to the containerDisk puller.""" + refs: list[str] = [] + disk = tmp_path / "container.qcow2" + disk.write_bytes(b"qcow2") + + def pull_container_disk(ref, **kwargs): + refs.append(ref) + return disk + + monkeypatch.setattr( + "cua_sandbox.registry.container_disk.pull_container_disk", pull_container_disk + ) + return refs, disk + + +@pytest.mark.parametrize( + "image, expected", + [ + (Image.windows(), DEFAULT_WINDOWS_REGISTRY_IMAGE), + (Image.linux(), DEFAULT_LINUX_REGISTRY_IMAGE), + ], +) +async def test_builtin_images_boot_the_pinned_container_disk(pulled, image, expected): + refs, disk = pulled + + assert await build.resolve_backing_disk(image) == disk + assert refs == [expected] + + +@pytest.mark.parametrize("image", [Image.windows("11"), Image.windows("10")]) +async def test_images_without_a_pinned_disk_fall_back_to_a_local_build(pulled, monkeypatch, image): + refs, _ = pulled + built = [] + + async def ensure_base_image(os_type, version): + built.append((os_type, version)) + return Path("/tmp/base.qcow2") + + monkeypatch.setattr(build, "ensure_base_image", ensure_base_image) + + assert await build.resolve_backing_disk(image) == Path("/tmp/base.qcow2") + assert built == [("windows", image.version)] + assert refs == [] + + +async def test_a_non_container_disk_ref_falls_back_to_a_local_build(monkeypatch, tmp_path): + """A lume/tart VM image in the registry is not a containerDisk; don't die on it.""" + built = [] + + def pull_container_disk(ref, **kwargs): + raise FileNotFoundError(f"OCI image {ref!r} does not contain /disk/disk.img") + + async def ensure_base_image(os_type, version): + built.append((os_type, version)) + return Path("/tmp/base.qcow2") + + monkeypatch.setattr( + "cua_sandbox.registry.container_disk.pull_container_disk", pull_container_disk + ) + monkeypatch.setattr(build, "ensure_base_image", ensure_base_image) + + assert await build.resolve_backing_disk(Image.windows()) == Path("/tmp/base.qcow2") + assert built == [("windows", "2022")] + + +async def test_session_disk_overlays_the_pinned_container_disk(pulled, monkeypatch, tmp_path): + """Windows no longer detours through the ISO-install base builder.""" + refs, disk = pulled + session = tmp_path / "session.qcow2" + overlays = [] + + async def ensure_base_image(os_type, version): + raise AssertionError("a pinned containerDisk must not trigger an ISO install") + + monkeypatch.setattr(build, "ensure_base_image", ensure_base_image) + monkeypatch.setattr(build, "session_overlay_path", lambda name: session) + monkeypatch.setattr( + build, + "create_overlay", + lambda backing, destination: overlays.append((backing, destination)), + ) + + result = await build.create_session_disk(Image.windows(), "demo") + + assert result == session + assert refs == [DEFAULT_WINDOWS_REGISTRY_IMAGE] + assert overlays == [(disk, session)] diff --git a/libs/python/cua-sandbox/tests/test_fleet_cloud_transport.py b/libs/python/cua-sandbox/tests/test_fleet_cloud_transport.py index 0b090f00e3..41e6de8704 100644 --- a/libs/python/cua-sandbox/tests/test_fleet_cloud_transport.py +++ b/libs/python/cua-sandbox/tests/test_fleet_cloud_transport.py @@ -80,6 +80,14 @@ def test_default_linux_image_becomes_typed_template_request(): ) +def test_default_windows_image_becomes_typed_template_request(): + request = FleetCloudTransport(image=Image.windows(), name="demo")._template_request() + + assert request.spec.vm_template.container_disk_image == ( + "public.ecr.aws/k5j5w0x5/cua-windows-2022:main-bac7daa3" + ) + + def test_pool_request_uses_the_single_sandbox_name_and_requested_replicas(): request = FleetCloudTransport( image=Image.from_registry("registry.example/workspace@sha256:abc"), @@ -142,7 +150,13 @@ async def list_claims(self, namespace): @pytest.mark.parametrize( "image", - [Image.linux("debian", "12"), Image.from_registry("example:latest").apt_install("curl")], + [ + Image.linux("debian", "12"), + # Client Windows has no pinned containerDisk, so the cloud cannot serve it. + Image.windows("11"), + Image.windows("10"), + Image.from_registry("example:latest").apt_install("curl"), + ], ) def test_rejects_unsupported_images(image): with pytest.raises(NotImplementedError): diff --git a/libs/python/cua-sandbox/tests/test_image.py b/libs/python/cua-sandbox/tests/test_image.py index 88b6d678ac..415cb33ab7 100644 --- a/libs/python/cua-sandbox/tests/test_image.py +++ b/libs/python/cua-sandbox/tests/test_image.py @@ -3,6 +3,7 @@ import pytest from cua_sandbox.image import ( DEFAULT_LINUX_REGISTRY_IMAGE, + DEFAULT_WINDOWS_REGISTRY_IMAGE, Image, cloud_registry_image, ) @@ -14,7 +15,35 @@ def test_linux_defaults(self): assert img.os_type == "linux" assert img.distro == "ubuntu" assert img.version == "24.04" - assert cloud_registry_image(img) == DEFAULT_LINUX_REGISTRY_IMAGE + + def test_macos(self): + img = Image.macos("15") + assert img.os_type == "macos" + + def test_windows(self): + img = Image.windows("11") + assert img.os_type == "windows" + assert img.distro == "windows" + assert img.version == "11" + + def test_windows_defaults_to_the_version_with_a_pinned_disk(self): + assert Image.windows().version == "2022" + + +class TestBuiltinRegistryImages: + """Built-in descriptors resolve to the pinned containerDisks the cloud boots.""" + + @pytest.mark.parametrize( + "image, expected", + [ + (Image.linux(), DEFAULT_LINUX_REGISTRY_IMAGE), + (Image.linux("ubuntu", "24.04"), DEFAULT_LINUX_REGISTRY_IMAGE), + (Image.windows(), DEFAULT_WINDOWS_REGISTRY_IMAGE), + (Image.windows("2022"), DEFAULT_WINDOWS_REGISTRY_IMAGE), + ], + ) + def test_builtin_descriptors_resolve_to_a_pinned_disk(self, image, expected): + assert cloud_registry_image(image) == expected @pytest.mark.parametrize( "image", @@ -22,24 +51,28 @@ def test_linux_defaults(self): Image.linux("debian", "12"), Image.linux("ubuntu", "22.04"), Image.linux("ubuntu", "24.04", kind="container"), - Image.windows(), + # Client Windows has no containerDisk; it installs from an ISO locally. + Image.windows("11"), + Image.windows("10"), + Image.windows("2022", kind="container"), + Image.macos("15"), + Image.android("14"), ], ) - def test_non_default_images_have_no_cloud_registry_default(self, image): + def test_other_descriptors_have_no_pinned_disk(self, image): assert cloud_registry_image(image) is None - def test_explicit_registry_is_cloud_override(self): - image = Image.linux()._with(_registry="registry.example/custom:latest") + @pytest.mark.parametrize("image", [Image.linux(), Image.windows()]) + def test_explicit_registry_overrides_the_builtin_pin(self, image): + override = image._with(_registry="registry.example/custom:latest") - assert cloud_registry_image(image) == "registry.example/custom:latest" + assert cloud_registry_image(override) == "registry.example/custom:latest" - def test_macos(self): - img = Image.macos("15") - assert img.os_type == "macos" - - def test_windows(self): - img = Image.windows("11") - assert img.os_type == "windows" + def test_pinned_refs_are_immutable_tags(self): + """A moving tag would break the promise that local and cloud boot the same bytes.""" + for ref in (DEFAULT_LINUX_REGISTRY_IMAGE, DEFAULT_WINDOWS_REGISTRY_IMAGE): + tag = ref.rsplit(":", 1)[1] + assert tag not in ("latest", "main"), ref def test_chaining_is_immutable(self): base = Image.linux() diff --git a/libs/python/cua-sandbox/tests/test_qemu_wsl.py b/libs/python/cua-sandbox/tests/test_qemu_wsl.py new file mode 100644 index 0000000000..330115623f --- /dev/null +++ b/libs/python/cua-sandbox/tests/test_qemu_wsl.py @@ -0,0 +1,75 @@ +"""The WSL2-hosted QEMU runtime has to translate Windows paths for the Linux QEMU.""" + +import json + +from cua_sandbox.runtime.qemu import QEMUWSL2Runtime + +OVERLAY = "/mnt/c/Users/demo/.cua/cua-sandbox/images/sessions/demo.qcow2" +WINDOWS_BACKING = r"C:\Users\demo\.cua\cua-sandbox\images\container-disks\abc\disk.qcow2" +WSL_BACKING = "/mnt/c/Users/demo/.cua/cua-sandbox/images/container-disks/abc/disk.qcow2" + + +class _Runtime(QEMUWSL2Runtime): + """Records the commands that would have run inside WSL.""" + + def __init__(self, info, *, fail=False): + super().__init__() + self.commands = [] + self._info = info + self._fail = fail + + def _wsl(self, cmd, timeout=30): + self.commands.append(cmd) + if cmd.startswith("qemu-img info"): + if self._fail: + raise RuntimeError("qemu-img: command not found") + return json.dumps(self._info) + return "" + + +def _rebases(runtime): + return [c for c in runtime.commands if c.startswith("qemu-img rebase")] + + +def test_a_windows_backing_path_is_rewritten_for_qemu_inside_wsl(): + """Otherwise QEMU reads the drive letter as a URI scheme: Unknown protocol 'C'.""" + runtime = _Runtime({"backing-filename": WINDOWS_BACKING}) + + runtime._rebase_backing_file(OVERLAY) + + assert _rebases(runtime) == [ + f"qemu-img rebase -u -f qcow2 -F qcow2 -b '{WSL_BACKING}' '{OVERLAY}'" + ] + + +def test_the_rebase_is_metadata_only(): + """-u must be present: without it qemu-img rewrites gigabytes of guest data.""" + runtime = _Runtime({"backing-filename": WINDOWS_BACKING}) + + runtime._rebase_backing_file(OVERLAY) + + assert " -u " in _rebases(runtime)[0] + + +def test_a_backing_path_already_in_wsl_form_is_left_alone(): + runtime = _Runtime({"backing-filename": WSL_BACKING}) + + runtime._rebase_backing_file(OVERLAY) + + assert _rebases(runtime) == [] + + +def test_a_disk_with_no_backing_file_is_left_alone(): + runtime = _Runtime({"format": "qcow2"}) + + runtime._rebase_backing_file(OVERLAY) + + assert _rebases(runtime) == [] + + +def test_an_uninspectable_disk_does_not_break_the_launch(): + runtime = _Runtime({}, fail=True) + + runtime._rebase_backing_file(OVERLAY) + + assert _rebases(runtime) == [] diff --git a/libs/python/cua-sandbox/tests/test_uefi_firmware.py b/libs/python/cua-sandbox/tests/test_uefi_firmware.py new file mode 100644 index 0000000000..8c1835e2a3 --- /dev/null +++ b/libs/python/cua-sandbox/tests/test_uefi_firmware.py @@ -0,0 +1,75 @@ +"""UEFI firmware discovery for local Windows VMs. + +OVMF ships as two halves that must be the same size class: a 4 MB `OVMF_CODE_4M.fd` +backed by a 2 MB `OVMF_VARS.fd` leaves the guest unable to boot. Both the bare-metal +and WSL-hosted runtimes therefore take code and vars from the same candidate entry. +""" + +from cua_sandbox.runtime.qemu import ( + _locate_uefi_firmware, + _locate_uefi_firmware_in, +) + +CODE_4M = "/usr/share/OVMF/OVMF_CODE_4M.fd" +VARS_4M = "/usr/share/OVMF/OVMF_VARS_4M.fd" +CODE_2M = "/usr/share/OVMF/OVMF_CODE.fd" +VARS_2M = "/usr/share/OVMF/OVMF_VARS.fd" + + +def _present(*paths: str): + return lambda path: path in set(paths) + + +class TestGuestFilesystemLookup: + """`_locate_uefi_firmware_in` backs the WSL path, where files live inside the distro.""" + + def test_finds_the_4m_pair_ubuntu_2404_ships(self): + assert _locate_uefi_firmware_in(_present(CODE_4M, VARS_4M)) == (CODE_4M, VARS_4M) + + def test_prefers_the_4m_pair_when_both_generations_are_installed(self): + found = _locate_uefi_firmware_in(_present(CODE_4M, VARS_4M, CODE_2M, VARS_2M)) + + assert found == (CODE_4M, VARS_4M) + + def test_does_not_pair_a_2m_firmware_with_a_4m_varstore(self): + """The defect this guards: two independent lookups would have matched these.""" + assert _locate_uefi_firmware_in(_present(CODE_2M, VARS_4M)) == (CODE_2M, None) + + def test_reports_firmware_without_a_varstore_rather_than_inventing_one(self): + assert _locate_uefi_firmware_in(_present(CODE_4M)) == (CODE_4M, None) + + def test_reports_nothing_when_ovmf_is_not_installed(self): + assert _locate_uefi_firmware_in(_present()) == (None, None) + + def test_ignores_candidates_relative_to_a_bundled_qemu_directory(self): + """A WSL distro has its own /usr/share; the Windows-side QEMU dir is not on it.""" + assert _locate_uefi_firmware_in(_present("share/edk2-x86_64-code.fd")) == (None, None) + + +class TestHostFilesystemLookup: + """`_locate_uefi_firmware` backs the bare-metal path and resolves real paths.""" + + def test_finds_a_pair_bundled_beside_qemu(self, tmp_path): + share = tmp_path / "share" + share.mkdir() + code = share / "edk2-x86_64-code.fd" + template = share / "edk2-i386-vars.fd" + code.write_bytes(b"code") + template.write_bytes(b"vars") + + assert _locate_uefi_firmware(tmp_path) == (code, template) + + def test_reports_bundled_firmware_without_its_varstore(self, tmp_path): + share = tmp_path / "share" + share.mkdir() + code = share / "edk2-x86_64-code.fd" + code.write_bytes(b"code") + + assert _locate_uefi_firmware(tmp_path) == (code, None) + + def test_reports_nothing_when_no_candidate_exists(self, tmp_path): + code, template = _locate_uefi_firmware(tmp_path) + + # A system OVMF install would still be found, so only assert the bundled miss. + assert code is None or code.is_absolute() + assert template is None or template.is_absolute() diff --git a/tests/integration/sandbox_sdk/test_windows_cloud_vm.py b/tests/integration/sandbox_sdk/test_windows_cloud_vm.py index 1021a34d0b..2f985866ab 100644 --- a/tests/integration/sandbox_sdk/test_windows_cloud_vm.py +++ b/tests/integration/sandbox_sdk/test_windows_cloud_vm.py @@ -1,6 +1,6 @@ """Run a cloud Windows VM in Python with the Cua Sandbox SDK. - async with Sandbox.ephemeral(Image.windows("11")) as sb: + async with Sandbox.ephemeral(Image.windows()) as sb: await sb.shell.run("ver") screenshot = await sb.screenshot() @@ -8,8 +8,11 @@ Requires CUA_API_KEY environment variable. Works on any host OS — no Windows needed. Contrast: - Image.windows("11") + local=False -> Cua cloud Windows VM (this file) - Image.windows("11") + local=True -> Hyper-V or QEMU VM on your machine + Image.windows() + local=False -> Cua cloud Windows VM (this file) + Image.windows() + local=True -> Hyper-V or QEMU VM on your machine + +Image.windows() is Windows Server 2022, which is the version with a pinned +containerDisk. Image.windows("11") is client Windows 11 and is local-install only. """ from __future__ import annotations @@ -30,7 +33,7 @@ def _has_cua_api_key() -> bool: @pytest.mark.skipif(not _has_cua_api_key(), reason="CUA_API_KEY not set") async def test_windows_cloud_vm(): async with Sandbox.ephemeral( - Image.windows("11"), + Image.windows(), name="example-windows-cloud-vm", ) as sb: result = await sb.shell.run("ver") @@ -42,7 +45,7 @@ async def test_windows_cloud_vm(): async def main(): async with Sandbox.ephemeral( - Image.windows("11"), + Image.windows(), name="example-windows-cloud-vm", ) as sb: result = await sb.shell.run("ver") diff --git a/tests/integration/sandbox_sdk/test_windows_local_vm.py b/tests/integration/sandbox_sdk/test_windows_local_vm.py index 209089b575..72fbe44313 100644 --- a/tests/integration/sandbox_sdk/test_windows_local_vm.py +++ b/tests/integration/sandbox_sdk/test_windows_local_vm.py @@ -1,6 +1,6 @@ """Run a local Windows VM in Python with the Cua Sandbox SDK. - async with Sandbox.ephemeral(Image.windows("11"), local=True) as sb: + async with Sandbox.ephemeral(Image.windows(), local=True) as sb: await sb.shell.run("ver") screenshot = await sb.screenshot() @@ -9,8 +9,11 @@ drop it to run on the Cua cloud instead. Contrast: - Image.windows("11") + local=True -> Hyper-V or QEMU VM (this file) - Image.windows("11") + local=False -> Cua cloud Windows VM + Image.windows() + local=True -> Hyper-V or QEMU VM (this file) + Image.windows() + local=False -> Cua cloud Windows VM + +Image.windows() is Windows Server 2022 and boots the pinned containerDisk. +Image.windows("11") is client Windows 11 and installs from an evaluation ISO. """ from __future__ import annotations @@ -40,7 +43,7 @@ def _has_qemu() -> bool: @pytest.mark.skipif(not _has_qemu(), reason="QEMU not available") async def test_windows_local_vm(): async with Sandbox.ephemeral( - Image.windows("11"), + Image.windows(), local=True, name="example-windows-local-vm", ) as sb: @@ -53,7 +56,7 @@ async def test_windows_local_vm(): async def main(): async with Sandbox.ephemeral( - Image.windows("11"), + Image.windows(), local=True, name="example-windows-local-vm", ) as sb: