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

364 lines
12 KiB
Python

"""Build orchestrator — resolve or build base images, apply user layers, create session overlays.
Implements the 3-layer qcow2 chain:
base (OS + computer-server) → user overlay (layers) → session overlay (ephemeral)
Usage::
from cua_sandbox.builder.build import resolve_image_disk
# Returns the disk path to boot — handles base building, layer caching, overlays
disk_path = await resolve_image_disk(image, name="my-sandbox")
"""
from __future__ import annotations
import asyncio
import json
import logging
from pathlib import Path
from typing import Optional
from cua_sandbox.builder.overlay import (
base_image_path,
create_overlay,
layers_hash,
session_overlay_path,
user_image_path,
)
from cua_sandbox.image import Image
logger = logging.getLogger(__name__)
# computer-server setup script — ported from setup-cua-server.ps1
# Runs inside the VM after first boot to install computer-server + scheduled task
SETUP_COMPUTER_SERVER_PS1 = r'''
$ErrorActionPreference = 'Continue'
# Install UV
powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex"
$uvPath = Join-Path $env:USERPROFILE ".local\bin"
$env:Path = "$uvPath;$env:Path"
# Create UV project
$ProjectDir = Join-Path $env:USERPROFILE "cua-server"
if (!(Test-Path (Join-Path $ProjectDir "pyproject.toml"))) {
New-Item -ItemType Directory -Force -Path $ProjectDir | Out-Null
& uv init --vcs none --no-readme --no-workspace --no-pin-python $ProjectDir
}
# Install cua-computer-server (no agent — VM only needs automation server)
& uv add --directory $ProjectDir cua-computer-server
# Install playwright + firefox
& uv add --directory $ProjectDir playwright
& uv run --directory $ProjectDir playwright install firefox
# Firewall rule for port 8000
netsh advfirewall firewall add rule name="CUA Computer Server" dir=in action=allow protocol=TCP localport=8000
# Create start script
$StartScript = Join-Path $ProjectDir "start-server.ps1"
@"
`$env:PYTHONUNBUFFERED = '1'
`$uvPath = Join-Path `$env:USERPROFILE '.local\bin'
`$env:Path = "`$uvPath;`$env:Path"
while (`$true) {
& uv run --directory '$ProjectDir' python -m computer_server --port 8000
Start-Sleep -Seconds 5
}
"@ | Set-Content -Path $StartScript -Encoding UTF8
# VBScript wrapper for hidden execution
$VbsWrapper = Join-Path $ProjectDir "start-server-hidden.vbs"
@"
Set objShell = CreateObject("WScript.Shell")
objShell.Run "powershell.exe -NoProfile -ExecutionPolicy Bypass -File ""$StartScript""", 0, False
"@ | Set-Content -Path $VbsWrapper -Encoding ASCII
# Scheduled task at logon
$TaskName = "Cua-Computer-Server"
$Username = $env:USERNAME
$existing = Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue
if ($existing) { Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false }
$Action = New-ScheduledTaskAction -Execute "wscript.exe" -Argument "`"$VbsWrapper`""
$UserId = "$env:COMPUTERNAME\$Username"
$Trigger = New-ScheduledTaskTrigger -AtLogOn -User $UserId
$Principal = New-ScheduledTaskPrincipal -UserId $UserId -LogonType Interactive -RunLevel Highest
$Settings = New-ScheduledTaskSettingsSet `
-AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable `
-RestartCount 999 -RestartInterval (New-TimeSpan -Minutes 1) `
-ExecutionTimeLimit (New-TimeSpan -Days 365) -Hidden
Register-ScheduledTask -TaskName $TaskName -Action $Action -Trigger $Trigger `
-Principal $Principal -Settings $Settings -Force | Out-Null
# Start the server now too
Start-Process wscript.exe -ArgumentList "`"$VbsWrapper`""
Write-Host "CUA Computer Server setup complete"
'''
# Linux equivalent
SETUP_COMPUTER_SERVER_SH = r"""#!/bin/bash
set -e
# Install uv
curl -LsSf https://astral.sh/uv/install.sh | sh
export PATH="$HOME/.local/bin:$PATH"
# Create uv project
mkdir -p ~/cua-server
cd ~/cua-server
[ -f pyproject.toml ] || uv init --vcs none --no-readme --no-workspace --no-pin-python .
# Install cua-computer-server
uv add cua-computer-server "cua-agent[all]"
# Install playwright + firefox
uv add playwright
uv run playwright install firefox
# Create systemd service
sudo tee /etc/systemd/system/cua-computer-server.service > /dev/null <<UNIT
[Unit]
Description=CUA Computer Server
After=network.target
[Service]
Type=simple
User=$USER
WorkingDirectory=$HOME/cua-server
ExecStart=$HOME/.local/bin/uv run python -m computer_server --port 8000
Restart=always
RestartSec=5
Environment=PYTHONUNBUFFERED=1
[Install]
WantedBy=multi-user.target
UNIT
sudo systemctl daemon-reload
sudo systemctl enable --now cua-computer-server
echo "CUA Computer Server setup complete"
"""
async def ensure_base_image(
os_type: str,
version: str,
*,
windows_iso: Optional[str] = None,
product_key: Optional[str] = None,
force: bool = False,
) -> Path:
"""Ensure the base image (OS + computer-server) exists. Build if needed.
Returns the path to the base qcow2.
"""
base_path = base_image_path(os_type, version)
_MIN_BASE_SIZE = {
"windows": 1 * 1024 * 1024 * 1024, # 1 GB — incomplete install guard
"linux": 100 * 1024 * 1024, # 100 MB
}
if base_path.exists() and not force:
min_size = _MIN_BASE_SIZE.get(os_type, 0)
if base_path.stat().st_size >= min_size:
logger.info(f"Using cached base image: {base_path}")
return base_path
logger.warning(
f"Cached base image {base_path} is too small "
f"({base_path.stat().st_size} bytes < {min_size}), rebuilding."
)
base_path.unlink()
logger.info(f"Building base image for {os_type} {version}...")
if os_type == "windows":
return await _build_windows_base(version, base_path, windows_iso, product_key)
elif os_type == "linux":
return await _build_linux_base(version, base_path)
else:
raise ValueError(f"Cannot build base image for os_type={os_type}")
async def _build_windows_base(
version: str,
base_path: Path,
windows_iso: Optional[str],
product_key: Optional[str],
) -> Path:
"""Build Windows base: unattend install + computer-server."""
from cua_sandbox.registry.qemu_builder import (
QEMUImageConfig,
build_image,
)
config = QEMUImageConfig(guest_os="windows", version=version)
work_dir = base_path.parent / "build"
# Phase 1: Unattended Windows install
raw_disk = build_image(
config, windows_iso=windows_iso, work_dir=work_dir, product_key=product_key
)
# Phase 2: Boot and install computer-server
logger.info("Installing computer-server into base image...")
# Boot the raw disk to install computer-server
# First we need to boot without expecting computer-server (it's not installed yet)
# We use a temporary overlay so we can retry if needed
import shutil
temp_disk = work_dir / "temp-boot.qcow2"
shutil.copy2(raw_disk, temp_disk)
# Boot and wait for Windows to be accessible (but not computer-server — it's not installed)
# We need to wait for Windows to boot then run the setup script
# This is tricky because we don't have computer-server yet...
# Use QMP or VNC to inject the setup script, or use the virtio-serial approach
#
# For now: the Autounattend.xml should include a FirstLogonCommand that
# downloads and runs the setup script. Let's add that to the builder.
logger.info(
"Base image built at %s. Computer-server must be installed via "
"Autounattend FirstLogonCommand or manual VNC session.",
raw_disk,
)
# Move the built disk to the base path
base_path.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(raw_disk), str(base_path))
return base_path
async def _build_linux_base(version: str, base_path: Path) -> Path:
"""Build Linux base: install from cloud image + computer-server."""
raise NotImplementedError("Linux base image building not yet implemented")
async def build_user_image(
image: Image,
base_path: Path,
*,
force: bool = False,
) -> Path:
"""Build a user image by applying Image layers on top of the base.
Creates a qcow2 overlay backed by base_path, boots it, runs all layers
via computer-server, then shuts down. The overlay is the user image.
Returns the path to the user image qcow2.
"""
if not image._layers:
# No user layers — just use the base
return base_path
lhash = layers_hash(list(image._layers))
user_path = user_image_path(image.os_type, image.version, lhash)
if user_path.exists() and not force:
logger.info(f"Using cached user image: {user_path} (hash={lhash})")
return user_path
logger.info(f"Building user image (hash={lhash}, {len(image._layers)} layers)...")
# Create overlay on top of base
create_overlay(base_path, user_path)
# Boot the overlay and execute layers
from cua_sandbox.runtime.qemu import QEMUBaremetalRuntime
runtime = QEMUBaremetalRuntime(api_port=18098, memory_mb=8192, cpu_count=4)
build_image = Image.from_file(str(user_path), os_type=image.os_type)
try:
info = await runtime.start(build_image, f"cua-build-{lhash}")
# Execute layers via computer-server
from cua_sandbox.builder.executor import LayerExecutor
executor = LayerExecutor(f"http://{info.host}:{info.api_port}")
await executor.execute_layers(list(image._layers))
# Shut down cleanly
logger.info("Layers applied, shutting down build VM...")
try:
await executor.run_command(
"shutdown /s /t 5" if image.os_type == "windows" else "sudo shutdown -h now"
)
except Exception:
pass
await asyncio.sleep(10)
finally:
await runtime.stop(f"cua-build-{lhash}")
logger.info(f"User image built: {user_path}")
# Save layer metadata
meta_path = user_path.with_suffix(".json")
meta_path.write_text(
json.dumps(
{
"os_type": image.os_type,
"version": image.version,
"layers": list(image._layers),
"base": str(base_path),
"hash": lhash,
},
indent=2,
)
)
return user_path
async def create_session_disk(
image: Image,
name: str,
*,
base_disk: Optional[Path] = None,
) -> Path:
"""Create a session overlay for a sandbox run.
If the image has layers and a cached user image exists, overlay on that.
Otherwise overlay on the base image. If no base exists, returns the
image's _disk_path directly (no overlay).
Returns the disk path to boot.
"""
# If image has a direct disk path and no layers, use it directly
if image._disk_path and not image._layers:
return Path(image._disk_path)
# Determine the backing disk
if base_disk:
backing = base_disk
elif image._disk_path:
backing = Path(image._disk_path)
else:
# Auto-build base if it doesn't exist
backing = await ensure_base_image(image.os_type, image.version)
# If there are user layers, check for cached user image
if image._layers:
lhash = layers_hash(list(image._layers))
user_disk = user_image_path(image.os_type, image.version, lhash)
if user_disk.exists():
backing = user_disk
else:
# Need to build the user image first
backing = await build_user_image(image, backing)
# Create ephemeral session overlay
session_disk = session_overlay_path(name)
if session_disk.exists():
session_disk.unlink()
create_overlay(backing, session_disk)
return session_disk