1#!/usr/bin/env python3
2
3import argparse
4import fcntl
5import hashlib
6import json
7import os
8from pathlib import Path
9import re
10import secrets
11import shutil
12import socket
13import struct
14import subprocess
15import tempfile
16import time
17import urllib.parse
18import urllib.error
19import urllib.request
20import uuid
21
22from lab_network import ensure_hub
23
24
25from env import ROOT, require, setting
26
27
28VM_HOME = Path(setting("ONE_VM_HOME") or ROOT / "lab-unset").expanduser()
29ISO = Path(setting("ONE_WIN7_ISO") or ROOT / "lab-unset/win7.iso")
30IMAGES = VM_HOME / "images"
31MEDIA = VM_HOME / "media"
32INSTANCES = VM_HOME / "instances"
33RUN = VM_HOME / "run"
34AGENT_ISO = MEDIA / "win7-agent.iso"
35TARGETS = 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.
38BASES = {
39 "win7": ("win7-office", 18777),
40 "win10": ("win10", 18774),
41 "win11": ("win11", 18775),
42}
43BUILD = "win7-build"
44UNATTEND = Path(__file__).with_name("unattend")
45FIRMWARE = Path("/opt/homebrew/share/qemu")
46NAME = re.compile(r"[a-z0-9](?:[a-z0-9-]{0,9}[a-z0-9])?")
47HOSTNAME = re.compile(r"[A-Z0-9](?:[A-Z0-9-]{0,13}[A-Z0-9])?")
48
49
50def 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
57def 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
66def build_disk(base):
67 return IMAGES / ("%s-build.qcow2" % BASES[base][0])
68
69
70def base_disk(base):
71 return IMAGES / ("%s-base.qcow2" % BASES[base][0])
72
73
74def base_manifest(base):
75 return IMAGES / ("%s-base.json" % BASES[base][0])
76
77
78def runtime(name):
79 root = RUN / name
80 return root, root / "qmp.sock", root / "qemu.pid", root / "qemu.log", root / "screen.png"
81
82
83def 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
91def 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
113def 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
132def 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
161def 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
178def 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
222def 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
235def 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
263def instance_path(name):
264 return INSTANCES / (name + ".json")
265
266
267def 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
272def 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
282def 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
289def 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
301def 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
317def mac_for(port):
318 return "52:54:00:01:%02x:%02x" % (port >> 8, port & 0xff)
319
320
321def uuid_for(name):
322 return str(uuid.uuid5(uuid.NAMESPACE_URL, "onenote-vm:" + name))
323
324
325def lab_mac(name):
326 tail = uuid.UUID(uuid_for(name)).bytes[-3:]
327 return "52:54:02:%02x:%02x:%02x" % tuple(tail)
328
329
330def 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
343def 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
361def 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
376def 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
429def 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
440def 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
460def 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
467def 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
478def 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
486def 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
502def 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
531def 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
567def 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
578def 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
658if __name__ == "__main__":
659 main()