| 1 | #!/usr/bin/env python3 |
| 2 | |
| 3 | import argparse |
| 4 | import fcntl |
| 5 | import hashlib |
| 6 | import json |
| 7 | import os |
| 8 | from pathlib import Path |
| 9 | import re |
| 10 | import secrets |
| 11 | import shutil |
| 12 | import socket |
| 13 | import struct |
| 14 | import subprocess |
| 15 | import tempfile |
| 16 | import time |
| 17 | import urllib.parse |
| 18 | import urllib.error |
| 19 | import urllib.request |
| 20 | import uuid |
| 21 | |
| 22 | from lab_network import ensure_hub |
| 23 | |
| 24 | |
| 25 | from env import ROOT, require, setting |
| 26 | |
| 27 | |
| 28 | VM_HOME = Path(setting("ONE_VM_HOME") or ROOT / "lab-unset").expanduser() |
| 29 | ISO = Path(setting("ONE_WIN7_ISO") or ROOT / "lab-unset/win7.iso") |
| 30 | IMAGES = VM_HOME / "images" |
| 31 | MEDIA = VM_HOME / "media" |
| 32 | INSTANCES = VM_HOME / "instances" |
| 33 | RUN = VM_HOME / "run" |
| 34 | AGENT_ISO = MEDIA / "win7-agent.iso" |
| 35 | TARGETS = VM_HOME / "targets.json" |
| 36 | # Image stem and build-VM control port. Windows 7 is installed by hand from licensed |
| 37 | # media; Windows 10 (x64) and 11 (arm64) install unattended from windows_media.py ISOs. |
| 38 | BASES = { |
| 39 | "win7": ("win7-office", 18777), |
| 40 | "win10": ("win10", 18774), |
| 41 | "win11": ("win11", 18775), |
| 42 | } |
| 43 | BUILD = "win7-build" |
| 44 | UNATTEND = Path(__file__).with_name("unattend") |
| 45 | FIRMWARE = Path("/opt/homebrew/share/qemu") |
| 46 | NAME = re.compile(r"[a-z0-9](?:[a-z0-9-]{0,9}[a-z0-9])?") |
| 47 | HOSTNAME = re.compile(r"[A-Z0-9](?:[A-Z0-9-]{0,13}[A-Z0-9])?") |
| 48 | |
| 49 | |
| 50 | def qemu(name): |
| 51 | path = shutil.which(name) or "/opt/homebrew/bin/" + name |
| 52 | if not Path(path).is_file(): |
| 53 | raise SystemExit("Install QEMU with: /opt/homebrew/bin/brew install qemu") |
| 54 | return path |
| 55 | |
| 56 | |
| 57 | def require_vm_home(): |
| 58 | if VM_HOME == ROOT / "lab-unset": |
| 59 | require("ONE_VM_HOME") |
| 60 | if len(VM_HOME.parts) > 2 and VM_HOME.parts[1] == "Volumes": |
| 61 | volume = Path("/Volumes") / VM_HOME.parts[2] |
| 62 | if not os.path.ismount(volume): |
| 63 | raise SystemExit("VM volume is not mounted: %s" % volume) |
| 64 | |
| 65 | |
| 66 | def build_disk(base): |
| 67 | return IMAGES / ("%s-build.qcow2" % BASES[base][0]) |
| 68 | |
| 69 | |
| 70 | def base_disk(base): |
| 71 | return IMAGES / ("%s-base.qcow2" % BASES[base][0]) |
| 72 | |
| 73 | |
| 74 | def base_manifest(base): |
| 75 | return IMAGES / ("%s-base.json" % BASES[base][0]) |
| 76 | |
| 77 | |
| 78 | def runtime(name): |
| 79 | root = RUN / name |
| 80 | return root, root / "qmp.sock", root / "qemu.pid", root / "qemu.log", root / "screen.png" |
| 81 | |
| 82 | |
| 83 | def running(name=BUILD): |
| 84 | try: |
| 85 | os.kill(int(runtime(name)[2].read_text()), 0) |
| 86 | return True |
| 87 | except (FileNotFoundError, ProcessLookupError, ValueError): |
| 88 | return False |
| 89 | |
| 90 | |
| 91 | def qmp(name, command, arguments=None): |
| 92 | qmp_socket = runtime(name)[1] |
| 93 | with socket.socket(socket.AF_UNIX) as client: |
| 94 | client.settimeout(5) |
| 95 | client.connect(str(qmp_socket)) |
| 96 | stream = client.makefile("rwb", buffering=0) |
| 97 | stream.readline() |
| 98 | stream.write(b'{"execute":"qmp_capabilities"}\n') |
| 99 | while "return" not in json.loads(stream.readline()): |
| 100 | pass |
| 101 | request = {"execute": command} |
| 102 | if arguments: |
| 103 | request["arguments"] = arguments |
| 104 | stream.write((json.dumps(request) + "\n").encode()) |
| 105 | while True: |
| 106 | response = json.loads(stream.readline()) |
| 107 | if "return" in response: |
| 108 | return response["return"] |
| 109 | if "error" in response: |
| 110 | raise SystemExit(response["error"]["desc"]) |
| 111 | |
| 112 | |
| 113 | def uefi_vars(path, template, width=1024, height=768): |
| 114 | """Write an edk2 variable store whose PlatformConfig sets the GOP resolution. |
| 115 | |
| 116 | Windows keeps the firmware's framebuffer mode, and 1024x768 is the largest mode |
| 117 | edk2 offers for ramfb on arm64.""" |
| 118 | image = bytearray(template.read_bytes()) |
| 119 | offset = struct.unpack_from("<H", image, 0x30)[0] + 28 |
| 120 | while struct.unpack_from("<H", image, offset)[0] == 0x55AA: |
| 121 | name_size, data_size = struct.unpack_from("<II", image, offset + 36) |
| 122 | offset = (offset + 60 + name_size + data_size + 3) & ~3 |
| 123 | name = "PlatformConfig\0".encode("utf-16-le") |
| 124 | data = struct.pack("<II", width, height) |
| 125 | guid = uuid.UUID("7235c51c-0c80-4cab-87ac-3b084a6304b1").bytes_le |
| 126 | record = struct.pack("<HBBIQ16sIII16s", 0x55AA, 0x3F, 0, 7, 0, bytes(16), 0, |
| 127 | len(name), len(data), guid) + name + data |
| 128 | image[offset:offset + len(record)] = record |
| 129 | path.write_bytes(image) |
| 130 | |
| 131 | |
| 132 | def machine(base, disk): |
| 133 | if base == "win7": |
| 134 | return [qemu("qemu-system-x86_64"), "-machine", "pc", "-accel", "tcg,thread=multi", |
| 135 | "-cpu", os.environ.get("ONE_VM_CPU", "qemu64"), |
| 136 | "-vga", "std", "-usb", "-device", "usb-tablet", |
| 137 | "-drive", "file=%s,if=ide,format=qcow2,cache=writeback" % disk], "e1000" |
| 138 | variables = disk.with_suffix(".vars.fd") |
| 139 | if base == "win10": |
| 140 | if not variables.exists(): |
| 141 | uefi_vars(variables, FIRMWARE / "edk2-i386-vars.fd") |
| 142 | return [qemu("qemu-system-x86_64"), "-machine", "q35", "-accel", "tcg,thread=multi", |
| 143 | "-cpu", "max", |
| 144 | "-drive", "if=pflash,format=raw,readonly=on,file=%s" % (FIRMWARE / "edk2-x86_64-code.fd"), |
| 145 | "-drive", "if=pflash,format=raw,file=%s" % variables, |
| 146 | "-vga", "std", "-usb", "-device", "usb-tablet", |
| 147 | "-drive", "file=%s,if=none,id=disk,format=qcow2,cache=writeback" % disk, |
| 148 | "-device", "ide-hd,drive=disk,bus=ide.0,bootindex=0"], "e1000" |
| 149 | if not variables.exists(): |
| 150 | uefi_vars(variables, FIRMWARE / "edk2-arm-vars.fd") |
| 151 | # Windows on Arm has inbox NVMe and xHCI drivers; lab-setup.cmd adds NetKVM. |
| 152 | return [qemu("qemu-system-aarch64"), "-machine", "virt", "-accel", "hvf", "-cpu", "host", |
| 153 | "-drive", "if=pflash,format=raw,readonly=on,file=%s" % (FIRMWARE / "edk2-aarch64-code.fd"), |
| 154 | "-drive", "if=pflash,format=raw,file=%s" % variables, |
| 155 | "-device", "ramfb", "-device", "qemu-xhci", |
| 156 | "-device", "usb-kbd", "-device", "usb-tablet", |
| 157 | "-drive", "file=%s,if=none,id=disk,format=qcow2,cache=writeback" % disk, |
| 158 | "-device", "nvme,drive=disk,serial=one,bootindex=0"], "virtio-net-pci" |
| 159 | |
| 160 | |
| 161 | def cdroms(base, images): |
| 162 | """Attach read-only discs. UEFI guests try the disk first, so an installer CD |
| 163 | boots only until Windows has made the disk bootable.""" |
| 164 | drives = [] |
| 165 | for index, image in enumerate(images, 1): |
| 166 | drive = "file=%s,file.locking=off,media=cdrom,readonly=on" % image |
| 167 | if base == "win7": |
| 168 | drives += ["-drive", drive + ",if=ide"] |
| 169 | continue |
| 170 | drives += ["-drive", drive + ",if=none,id=cd%d" % index, "-device"] |
| 171 | if base == "win10": |
| 172 | drives.append("ide-cd,drive=cd%d,bus=ide.%d,bootindex=%d" % (index, index, index)) |
| 173 | else: |
| 174 | drives.append("usb-storage,drive=cd%d,bootindex=%d" % (index, index)) |
| 175 | return drives |
| 176 | |
| 177 | |
| 178 | def launch(name, base, disk, port, mac, cpus, memory_mb, display, images, answers=None): |
| 179 | require_vm_home() |
| 180 | if running(name): |
| 181 | raise SystemExit("Windows is already running: %s" % name) |
| 182 | root, qmp_socket, pid, log_path, _shot = runtime(name) |
| 183 | root.mkdir(parents=True, exist_ok=True) |
| 184 | qmp_socket.unlink(missing_ok=True) |
| 185 | lab_socket = ensure_hub(VM_HOME) |
| 186 | command, nic = machine(base, disk) |
| 187 | command += [ |
| 188 | # crash.py matches this name to confirm a process belongs to the clone. |
| 189 | "-name", "OneNote Windows 7 " + name, |
| 190 | "-smp", str(cpus), |
| 191 | "-m", str(memory_mb), |
| 192 | "-display", display, |
| 193 | "-netdev", "user,id=control,hostfwd=tcp:127.0.0.1:%d-:8777" % port, |
| 194 | "-device", "%s,netdev=control,mac=%s" % (nic, mac), |
| 195 | "-netdev", "vde,id=lab,sock=%s" % lab_socket, |
| 196 | "-device", "%s,netdev=lab,mac=%s" % (nic, lab_mac(name)), |
| 197 | "-uuid", uuid_for(name), |
| 198 | "-rtc", "base=localtime,clock=host,driftfix=slew", |
| 199 | "-qmp", "unix:%s,server=on,wait=off" % qmp_socket, |
| 200 | "-pidfile", str(pid), |
| 201 | ] + cdroms(base, images) |
| 202 | if answers: |
| 203 | # Setup reads autounattend.xml from the root of a removable drive. |
| 204 | command += ["-drive", "file=fat:%s,format=raw,if=none,id=answers,readonly=on" % answers, |
| 205 | "-device", "usb-storage,drive=answers,removable=on"] |
| 206 | with log_path.open("ab") as log: |
| 207 | subprocess.Popen( |
| 208 | command, |
| 209 | stdin=subprocess.DEVNULL, |
| 210 | stdout=log, |
| 211 | stderr=log, |
| 212 | start_new_session=True, |
| 213 | ) |
| 214 | for _ in range(50): |
| 215 | if running(name): |
| 216 | print("Windows opened: %s" % name) |
| 217 | return |
| 218 | time.sleep(0.1) |
| 219 | raise SystemExit("Windows did not open. Check: %s" % log_path) |
| 220 | |
| 221 | |
| 222 | def stage_answers(base): |
| 223 | """Copy unattend/ into the build's runtime directory with this base filled in.""" |
| 224 | root = runtime(base + "-build")[0] / "answers" |
| 225 | shutil.rmtree(root, ignore_errors=True) |
| 226 | shutil.copytree(UNATTEND, root) |
| 227 | answers = root / "autounattend.xml" |
| 228 | text = answers.read_text().replace("{arch}", "arm64" if base == "win11" else "amd64") |
| 229 | answers.write_text(text.replace("{hostname}", "ONE-" + base.upper())) |
| 230 | if base == "win11": |
| 231 | shutil.copytree(MEDIA / "netkvm-arm64", root / "netkvm") |
| 232 | return root |
| 233 | |
| 234 | |
| 235 | def start_build(base, install, display): |
| 236 | disk = build_disk(base) |
| 237 | iso = ISO if base == "win7" else MEDIA / ("%s.iso" % base) |
| 238 | if install and not iso.is_file(): |
| 239 | raise SystemExit("Windows ISO not found: %s (run ./windows_media.py %s)" % (iso, base)) |
| 240 | if not disk.exists(): |
| 241 | if not install: |
| 242 | raise SystemExit("No Windows disk found. Run: ./vm.py install --base %s" % base) |
| 243 | IMAGES.mkdir(parents=True, exist_ok=True) |
| 244 | subprocess.run( |
| 245 | [qemu("qemu-img"), "create", "-f", "qcow2", str(disk), "64G"], |
| 246 | check=True, |
| 247 | ) |
| 248 | images, answers = [], None |
| 249 | if install: |
| 250 | images.append(iso) |
| 251 | if base != "win7": |
| 252 | build_agent_iso() |
| 253 | images.append(AGENT_ISO) |
| 254 | answers = stage_answers(base) |
| 255 | elif AGENT_ISO.exists(): |
| 256 | images.append(AGENT_ISO) |
| 257 | port = BASES[base][1] |
| 258 | # The Windows 7 build is finished by hand, so it opens a window by default. |
| 259 | launch(base + "-build", base, disk, port, mac_for(port), 4, 4096, |
| 260 | "cocoa" if display or base == "win7" else "none", images, answers) |
| 261 | |
| 262 | |
| 263 | def instance_path(name): |
| 264 | return INSTANCES / (name + ".json") |
| 265 | |
| 266 | |
| 267 | def validate_name(name): |
| 268 | if not NAME.fullmatch(name): |
| 269 | raise SystemExit("VM name must be 1-11 lowercase letters, digits, or hyphens") |
| 270 | |
| 271 | |
| 272 | def load_instance(name): |
| 273 | validate_name(name) |
| 274 | try: |
| 275 | return json.loads(instance_path(name).read_text()) |
| 276 | except FileNotFoundError: |
| 277 | raise SystemExit("VM does not exist: %s" % name) |
| 278 | except (OSError, ValueError) as e: |
| 279 | raise SystemExit("Cannot read VM %s: %s" % (name, e)) |
| 280 | |
| 281 | |
| 282 | def atomic_json(path, value): |
| 283 | path.parent.mkdir(parents=True, exist_ok=True) |
| 284 | temporary = path.with_suffix(path.suffix + ".tmp") |
| 285 | temporary.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n") |
| 286 | temporary.replace(path) |
| 287 | |
| 288 | |
| 289 | def configs(): |
| 290 | if not INSTANCES.exists(): |
| 291 | return [] |
| 292 | rows = [] |
| 293 | for path in sorted(INSTANCES.glob("*.json")): |
| 294 | try: |
| 295 | rows.append((path.stem, json.loads(path.read_text()))) |
| 296 | except (OSError, ValueError) as e: |
| 297 | raise SystemExit("Cannot read VM manifest %s: %s" % (path, e)) |
| 298 | return rows |
| 299 | |
| 300 | |
| 301 | def available_port(requested=None): |
| 302 | used = {port for _stem, port in BASES.values()} |
| 303 | used.update(config["port"] for _name, config in configs()) |
| 304 | candidates = [requested] if requested else range(18778, 18878) |
| 305 | for port in candidates: |
| 306 | if port is None or port < 1024 or port > 65535 or port in used: |
| 307 | continue |
| 308 | with socket.socket() as probe: |
| 309 | try: |
| 310 | probe.bind(("127.0.0.1", port)) |
| 311 | except OSError: |
| 312 | continue |
| 313 | return port |
| 314 | raise SystemExit("No unused control port is available") |
| 315 | |
| 316 | |
| 317 | def mac_for(port): |
| 318 | return "52:54:00:01:%02x:%02x" % (port >> 8, port & 0xff) |
| 319 | |
| 320 | |
| 321 | def uuid_for(name): |
| 322 | return str(uuid.uuid5(uuid.NAMESPACE_URL, "onenote-vm:" + name)) |
| 323 | |
| 324 | |
| 325 | def lab_mac(name): |
| 326 | tail = uuid.UUID(uuid_for(name)).bytes[-3:] |
| 327 | return "52:54:02:%02x:%02x:%02x" % tuple(tail) |
| 328 | |
| 329 | |
| 330 | def make_instance_iso(path, hostname, token): |
| 331 | path.parent.mkdir(parents=True, exist_ok=True) |
| 332 | with tempfile.TemporaryDirectory(prefix="one-vm-") as temporary: |
| 333 | root = Path(temporary) |
| 334 | (root / "onenote-vm.ini").write_text( |
| 335 | "HOSTNAME=%s\r\nTOKEN=%s\r\n" % (hostname, token) |
| 336 | ) |
| 337 | subprocess.run([ |
| 338 | "hdiutil", "makehybrid", "-quiet", "-iso", "-joliet", |
| 339 | "-default-volume-name", "ONEVM", "-o", str(path), str(root), |
| 340 | ], check=True) |
| 341 | |
| 342 | |
| 343 | def build_agent_iso(): |
| 344 | require_vm_home() |
| 345 | MEDIA.mkdir(parents=True, exist_ok=True) |
| 346 | temporary = AGENT_ISO.with_suffix(".tmp.iso") |
| 347 | temporary.unlink(missing_ok=True) |
| 348 | try: |
| 349 | subprocess.run([ |
| 350 | "hdiutil", "makehybrid", "-quiet", "-iso", "-joliet", |
| 351 | "-default-volume-name", "WIN7_AGENT", "-o", str(temporary), |
| 352 | str(Path(__file__).with_name("payload")), |
| 353 | ], check=True) |
| 354 | temporary.replace(AGENT_ISO) |
| 355 | except Exception: |
| 356 | temporary.unlink(missing_ok=True) |
| 357 | raise |
| 358 | print(AGENT_ISO) |
| 359 | |
| 360 | |
| 361 | def update_target(name, port=None, token=None): |
| 362 | try: |
| 363 | targets = json.loads(TARGETS.read_text()) |
| 364 | except FileNotFoundError: |
| 365 | targets = {} |
| 366 | except (OSError, ValueError) as e: |
| 367 | raise SystemExit("Cannot read %s: %s" % (TARGETS, e)) |
| 368 | if port is None: |
| 369 | targets.pop(name, None) |
| 370 | else: |
| 371 | targets[name] = {"base": "http://127.0.0.1:%d" % port, "token": token} |
| 372 | atomic_json(TARGETS, targets) |
| 373 | TARGETS.chmod(0o600) |
| 374 | |
| 375 | |
| 376 | def create_instance(name, hostname=None, cpus=2, memory_mb=4096, port=None, base="win7"): |
| 377 | require_vm_home() |
| 378 | validate_name(name) |
| 379 | if name == "local" or name in ("%s-build" % b for b in BASES): |
| 380 | raise SystemExit("VM name is reserved: %s" % name) |
| 381 | hostname = (hostname or ("ONE-" + name)).upper() |
| 382 | if not HOSTNAME.fullmatch(hostname): |
| 383 | raise SystemExit("Hostname must be 1-15 letters, digits, or hyphens") |
| 384 | if not 1 <= cpus <= 16: |
| 385 | raise SystemExit("CPU count must be between 1 and 16") |
| 386 | if not 1024 <= memory_mb <= 65536: |
| 387 | raise SystemExit("Memory must be between 1024 and 65536 MiB") |
| 388 | if not base_disk(base).is_file() or not base_manifest(base).is_file(): |
| 389 | raise SystemExit("No sealed %s base image. Finish the build, then run: " |
| 390 | "./vm.py seal --base %s" % (base, base)) |
| 391 | VM_HOME.mkdir(parents=True, exist_ok=True) |
| 392 | with (VM_HOME / ".lock").open("a") as lock: |
| 393 | fcntl.flock(lock, fcntl.LOCK_EX) |
| 394 | if instance_path(name).exists(): |
| 395 | raise SystemExit("VM already exists: %s" % name) |
| 396 | if any(c.get("hostname", "").upper() == hostname.upper() for _n, c in configs()): |
| 397 | raise SystemExit("Hostname is already in use: %s" % hostname) |
| 398 | port = available_port(port) |
| 399 | token = secrets.token_urlsafe(24) |
| 400 | overlay = IMAGES / "instances" / (name + ".qcow2") |
| 401 | instance_iso = MEDIA / "instances" / (name + ".iso") |
| 402 | if overlay.exists() or instance_iso.exists(): |
| 403 | raise SystemExit("VM artifacts already exist without a manifest: %s" % name) |
| 404 | overlay.parent.mkdir(parents=True, exist_ok=True) |
| 405 | temporary = overlay.with_suffix(".tmp.qcow2") |
| 406 | try: |
| 407 | subprocess.run([ |
| 408 | qemu("qemu-img"), "create", "-f", "qcow2", "-F", "qcow2", |
| 409 | "-b", str(base_disk(base)), str(temporary), |
| 410 | ], check=True) |
| 411 | make_instance_iso(instance_iso, hostname, token) |
| 412 | instance_iso.chmod(0o600) |
| 413 | temporary.replace(overlay) |
| 414 | config = {"base": base, "cpus": cpus, "hostname": hostname, |
| 415 | "memory_mb": memory_mb, "port": port, "token": token} |
| 416 | atomic_json(instance_path(name), config) |
| 417 | instance_path(name).chmod(0o600) |
| 418 | update_target(name, port, token) |
| 419 | except Exception: |
| 420 | temporary.unlink(missing_ok=True) |
| 421 | instance_iso.unlink(missing_ok=True) |
| 422 | overlay.unlink(missing_ok=True) |
| 423 | instance_path(name).unlink(missing_ok=True) |
| 424 | raise |
| 425 | print(json.dumps({"base": base, "cpus": cpus, "hostname": hostname, |
| 426 | "memory_mb": memory_mb, "name": name, "port": port}, sort_keys=True)) |
| 427 | |
| 428 | |
| 429 | def start_instance(name, display=False): |
| 430 | config = load_instance(name) |
| 431 | overlay = IMAGES / "instances" / (name + ".qcow2") |
| 432 | instance_iso = MEDIA / "instances" / (name + ".iso") |
| 433 | if not overlay.is_file() or not instance_iso.is_file(): |
| 434 | raise SystemExit("VM artifacts are incomplete: %s" % name) |
| 435 | images = [image for image in (AGENT_ISO, instance_iso) if image.is_file()] |
| 436 | launch(name, config.get("base", "win7"), overlay, config["port"], mac_for(config["port"]), |
| 437 | config["cpus"], config["memory_mb"], "cocoa" if display else "none", images) |
| 438 | |
| 439 | |
| 440 | def wait_instance(name, timeout): |
| 441 | config = load_instance(name) |
| 442 | deadline = time.monotonic() + timeout |
| 443 | request = urllib.request.Request( |
| 444 | "http://127.0.0.1:%d/health" % config["port"], |
| 445 | headers={"X-Win7-Token": config["token"]}, |
| 446 | ) |
| 447 | while time.monotonic() < deadline: |
| 448 | try: |
| 449 | with urllib.request.urlopen(request, timeout=2) as response: |
| 450 | health = json.load(response) |
| 451 | if health.get("hostname", "").upper() == config["hostname"]: |
| 452 | print("Ready: %s (%s)" % (name, config["hostname"])) |
| 453 | return |
| 454 | except (urllib.error.URLError, OSError, ValueError): |
| 455 | pass |
| 456 | time.sleep(1) |
| 457 | raise SystemExit("VM did not become ready within %d seconds: %s" % (timeout, name)) |
| 458 | |
| 459 | |
| 460 | def poweroff(name=BUILD): |
| 461 | if not running(name): |
| 462 | raise SystemExit("Windows is not running: %s" % name) |
| 463 | qmp(name, "system_powerdown") |
| 464 | print("Windows is shutting down: %s" % name) |
| 465 | |
| 466 | |
| 467 | def shutdown(name, timeout): |
| 468 | poweroff(name) |
| 469 | deadline = time.monotonic() + timeout |
| 470 | while time.monotonic() < deadline: |
| 471 | if not running(name): |
| 472 | print("Windows stopped: %s" % name) |
| 473 | return |
| 474 | time.sleep(1) |
| 475 | raise SystemExit("Windows did not stop within %d seconds: %s" % (timeout, name)) |
| 476 | |
| 477 | |
| 478 | def screenshot(name=BUILD): |
| 479 | if not running(name): |
| 480 | raise SystemExit("Windows is not running: %s" % name) |
| 481 | shot = runtime(name)[4] |
| 482 | qmp(name, "screendump", {"filename": str(shot), "format": "png"}) |
| 483 | print(shot) |
| 484 | |
| 485 | |
| 486 | def delete_instance(name): |
| 487 | require_vm_home() |
| 488 | with (VM_HOME / ".lock").open("a") as lock: |
| 489 | fcntl.flock(lock, fcntl.LOCK_EX) |
| 490 | load_instance(name) |
| 491 | if running(name): |
| 492 | raise SystemExit("Shut down Windows before deleting: %s" % name) |
| 493 | (IMAGES / "instances" / (name + ".qcow2")).unlink(missing_ok=True) |
| 494 | (IMAGES / "instances" / (name + ".vars.fd")).unlink(missing_ok=True) |
| 495 | (MEDIA / "instances" / (name + ".iso")).unlink(missing_ok=True) |
| 496 | instance_path(name).unlink() |
| 497 | shutil.rmtree(runtime(name)[0], ignore_errors=True) |
| 498 | update_target(name) |
| 499 | print("Deleted VM: %s" % name) |
| 500 | |
| 501 | |
| 502 | def seal(base="win7"): |
| 503 | require_vm_home() |
| 504 | disk, sealed, manifest = build_disk(base), base_disk(base), base_manifest(base) |
| 505 | if running(base + "-build"): |
| 506 | raise SystemExit("Shut down Windows before sealing the base image") |
| 507 | if not disk.exists(): |
| 508 | raise SystemExit("No Windows build disk found") |
| 509 | temporary = sealed.with_suffix(".tmp.qcow2") |
| 510 | if sealed.exists() or temporary.exists(): |
| 511 | raise SystemExit("Move the existing base image before sealing another") |
| 512 | subprocess.run([qemu("qemu-img"), "check", str(disk)], check=True) |
| 513 | subprocess.run([qemu("qemu-img"), "convert", "-p", "-O", "qcow2", |
| 514 | "-o", "lazy_refcounts=off", str(disk), str(temporary)], check=True) |
| 515 | subprocess.run([qemu("qemu-img"), "check", str(temporary)], check=True) |
| 516 | temporary.replace(sealed) |
| 517 | digest = hashlib.sha256() |
| 518 | with sealed.open("rb") as image: |
| 519 | while chunk := image.read(8 * 1024 * 1024): |
| 520 | digest.update(chunk) |
| 521 | info = json.loads(subprocess.check_output([ |
| 522 | qemu("qemu-img"), "info", "--output=json", str(sealed), |
| 523 | ])) |
| 524 | atomic_json(manifest, {"file": sealed.name, "format": info["format"], |
| 525 | "sha256": digest.hexdigest(), |
| 526 | "virtual_size": info["virtual-size"]}) |
| 527 | sealed.chmod(0o444) |
| 528 | print(manifest) |
| 529 | |
| 530 | |
| 531 | def fetch_base(manifest_url, base="win7"): |
| 532 | require_vm_home() |
| 533 | sealed, manifest_path = base_disk(base), base_manifest(base) |
| 534 | if sealed.exists() or manifest_path.exists(): |
| 535 | raise SystemExit("Move the existing base image before fetching another") |
| 536 | headers = {} |
| 537 | if os.environ.get("ONE_VM_AUTHORIZATION"): |
| 538 | headers["Authorization"] = os.environ["ONE_VM_AUTHORIZATION"] |
| 539 | with urllib.request.urlopen(urllib.request.Request(manifest_url, headers=headers)) as response: |
| 540 | manifest = json.load(response) |
| 541 | expected = manifest.get("sha256", "").lower() |
| 542 | if not re.fullmatch(r"[0-9a-f]{64}", expected): |
| 543 | raise SystemExit("Base manifest has no valid SHA-256") |
| 544 | image_url = urllib.parse.urljoin(manifest_url, manifest.get("file", "")) |
| 545 | IMAGES.mkdir(parents=True, exist_ok=True) |
| 546 | temporary = sealed.with_suffix(".download.qcow2") |
| 547 | digest = hashlib.sha256() |
| 548 | try: |
| 549 | with urllib.request.urlopen(urllib.request.Request(image_url, headers=headers)) as response: |
| 550 | with temporary.open("wb") as output: |
| 551 | while chunk := response.read(8 * 1024 * 1024): |
| 552 | output.write(chunk) |
| 553 | digest.update(chunk) |
| 554 | if digest.hexdigest() != expected: |
| 555 | temporary.unlink(missing_ok=True) |
| 556 | raise SystemExit("Downloaded base image failed SHA-256 verification") |
| 557 | subprocess.run([qemu("qemu-img"), "check", str(temporary)], check=True) |
| 558 | temporary.replace(sealed) |
| 559 | sealed.chmod(0o444) |
| 560 | atomic_json(manifest_path, dict(manifest, file=sealed.name)) |
| 561 | except Exception: |
| 562 | temporary.unlink(missing_ok=True) |
| 563 | raise |
| 564 | print(manifest_path) |
| 565 | |
| 566 | |
| 567 | def list_instances(): |
| 568 | rows = configs() |
| 569 | if not rows: |
| 570 | print("No Windows VMs.") |
| 571 | return |
| 572 | for name, config in rows: |
| 573 | state = "running" if running(name) else "stopped" |
| 574 | print("%-11s %-5s %-15s %-7s http://127.0.0.1:%d" % |
| 575 | (name, config.get("base", "win7"), config["hostname"], state, config["port"])) |
| 576 | |
| 577 | |
| 578 | def main(): |
| 579 | parser = argparse.ArgumentParser(description="Run OneNote Windows lab VMs") |
| 580 | commands = parser.add_subparsers(dest="command", required=True) |
| 581 | base_option = {"choices": sorted(BASES), "default": "win7"} |
| 582 | for verb in ("install", "run"): |
| 583 | build = commands.add_parser(verb) |
| 584 | build.add_argument("--base", **base_option) |
| 585 | build.add_argument("--display", action="store_true") |
| 586 | status = commands.add_parser("status") |
| 587 | status.add_argument("name", nargs="?") |
| 588 | power = commands.add_parser("poweroff") |
| 589 | power.add_argument("name", nargs="?", default=BUILD) |
| 590 | shot = commands.add_parser("screenshot") |
| 591 | shot.add_argument("name", nargs="?", default=BUILD) |
| 592 | commands.add_parser("seal").add_argument("--base", **base_option) |
| 593 | commands.add_parser("media") |
| 594 | up = commands.add_parser("up") |
| 595 | up.add_argument("name") |
| 596 | up.add_argument("--base", **base_option) |
| 597 | up.add_argument("--hostname") |
| 598 | up.add_argument("--cpus", type=int, default=2) |
| 599 | up.add_argument("--memory", type=int, default=4096, dest="memory_mb") |
| 600 | up.add_argument("--port", type=int) |
| 601 | up.add_argument("--display", action="store_true") |
| 602 | up.add_argument("--wait", action="store_true") |
| 603 | up.add_argument("--timeout", type=int, default=300) |
| 604 | down = commands.add_parser("down") |
| 605 | down.add_argument("name") |
| 606 | down.add_argument("--timeout", type=int, default=60) |
| 607 | down.add_argument("--preserve-machine", action="store_true") |
| 608 | fetch = commands.add_parser("fetch") |
| 609 | fetch.add_argument("manifest_url") |
| 610 | fetch.add_argument("--base", **base_option) |
| 611 | args = parser.parse_args() |
| 612 | if args.command in ("install", "run"): |
| 613 | start_build(args.base, args.command == "install", args.display) |
| 614 | elif args.command == "status": |
| 615 | if args.name: |
| 616 | if args.name in ("%s-build" % b for b in BASES): |
| 617 | print("running" if running(args.name) else "stopped") |
| 618 | elif not instance_path(args.name).exists(): |
| 619 | print("absent") |
| 620 | else: |
| 621 | load_instance(args.name) |
| 622 | print("running" if running(args.name) else "stopped") |
| 623 | else: |
| 624 | list_instances() |
| 625 | elif args.command == "poweroff": |
| 626 | poweroff(args.name) |
| 627 | elif args.command == "screenshot": |
| 628 | screenshot(args.name) |
| 629 | elif args.command == "seal": |
| 630 | seal(args.base) |
| 631 | elif args.command == "media": |
| 632 | build_agent_iso() |
| 633 | elif args.command == "up": |
| 634 | if not instance_path(args.name).exists(): |
| 635 | create_instance(args.name, args.hostname, args.cpus, args.memory_mb, args.port, |
| 636 | args.base) |
| 637 | if running(args.name): |
| 638 | print("Windows already running: %s" % args.name) |
| 639 | else: |
| 640 | start_instance(args.name, args.display) |
| 641 | if args.wait: |
| 642 | wait_instance(args.name, args.timeout) |
| 643 | elif args.command == "down": |
| 644 | if not instance_path(args.name).exists(): |
| 645 | print("VM absent: %s" % args.name) |
| 646 | else: |
| 647 | load_instance(args.name) |
| 648 | if running(args.name): |
| 649 | shutdown(args.name, args.timeout) |
| 650 | if args.preserve_machine: |
| 651 | print("Preserved VM: %s" % args.name) |
| 652 | else: |
| 653 | delete_instance(args.name) |
| 654 | else: |
| 655 | fetch_base(args.manifest_url, args.base) |
| 656 | |
| 657 | |
| 658 | if __name__ == "__main__": |
| 659 | main() |