1#!/usr/bin/env python3
2import json
3import os
4from pathlib import Path
5import re
6import secrets
7import shutil
8import signal
9import stat
10import tempfile
11import time
12import subprocess
13import sys
14import xml.etree.ElementTree as ET
15
16
17DISKS = Path("/srv/vm")
18IMAGES = Path(os.environ.get("STUDIO_VM_IMAGES_ROOT", "/srv/clover/Media/vm"))
19PRESETS = Path(os.environ.get("STUDIO_VM_PRESETS_ROOT", str(IMAGES / "Presets")))
20UPLOADS = Path(os.environ.get("STUDIO_VM_UPLOADS_ROOT", str(IMAGES / "Install Disks")))
21NS = "{https://paperclover.net/studio}"
22NAME = re.compile(r"[a-z0-9][a-z0-9-]{0,62}\Z")
23GUEST_CHANNEL = json.loads((Path(__file__).resolve().parent.parent / "guest/protocol.json").read_text())["channel"]
24PROFILES = {
25 "linux-x86-virtio": {
26 "arch": "x86_64", "machine": "q35", "platform": "linux", "firmware": None,
27 "disk": ("vda", "virtio"), "cd": ("sdb", "sata"), "seed": ("sdc", "sata"),
28 "network": "virtio", "usb": "qemu-xhci", "clock": "utc",
29 },
30 "linux-aarch64-virtio": {
31 "arch": "aarch64", "machine": "virt", "platform": "linux", "firmware": "uefi",
32 "disk": ("vda", "virtio"), "cd": ("sda", "scsi"), "seed": ("sdb", "scsi"),
33 "network": "virtio", "usb": "qemu-xhci", "clock": "utc", "emulated": True,
34 "scsi": True, "video": "virtio", "keyboard": "usb", "serial": "ttyAMA0",
35 },
36 "windows-aarch64-nvme": {
37 "arch": "aarch64", "machine": "virt-11.1", "platform": "windows", "firmware": "uefi",
38 "disk": ("nvme0n1", "nvme"), "cd": ("sda", "usb"), "network": "virtio",
39 "usb": "qemu-xhci", "clock": "utc", "emulated": True, "secure": True,
40 "video": "virtio", "keyboard": "usb", "gic": "3", "tpm": "tpm-tis",
41 },
42 "windows-q35-uefi": {
43 "arch": "x86_64", "machine": "q35", "platform": "windows", "firmware": "uefi",
44 "disk": ("sda", "sata"), "cd": ("sdb", "sata"), "network": "e1000e",
45 "usb": "qemu-xhci", "clock": "localtime", "tpm": "tpm-crb", "cpu_mode": "host-passthrough",
46 },
47 "windows-q35-secure": {
48 "arch": "x86_64", "machine": "q35", "platform": "windows", "firmware": "uefi",
49 "disk": ("sda", "sata"), "cd": ("sdb", "sata"), "network": "e1000e",
50 "usb": "qemu-xhci", "clock": "utc", "cpu_mode": "host-passthrough",
51 "tpm": "tpm-crb", "secure": True, "smm": True,
52 },
53 "windows-q35-bios": {
54 "arch": "x86_64", "machine": "pc-q35-10.2", "platform": "windows", "firmware": "bios",
55 "disk": ("sda", "sata"), "cd": ("sdb", "sata"), "network": "e1000e",
56 "usb": "qemu-xhci", "clock": "localtime", "balloon": "none",
57 },
58 "windows-legacy-ide": {
59 "arch": "x86_64", "machine": "pc-i440fx-10.2", "platform": "windows", "firmware": "bios",
60 "disk": ("hda", "ide"), "cd": ("hdc", "ide"), "network": "e1000",
61 "usb": "piix3-uhci", "clock": "localtime", "balloon": "none",
62 },
63 "macos-legacy-q35": {
64 "arch": "x86_64", "machine": "pc-q35-10.2", "platform": "macos", "firmware": "uefi",
65 "disk": ("sda", "sata"), "cd": ("sdb", "sata"), "network": "e1000-82545em",
66 "usb": "ich9-ehci1", "clock": "utc", "cpu": "Penryn", "emulated": True,
67 "keyboard": "usb", "balloon": "none",
68 },
69}
70ACTION_FIELDS = {
71 "node": None, "domains": None, "stats": None,
72 "create": {"name", "description", "image", "vcpus", "memory", "disk", "autostart", "start", "mode", "firmware", "platform", "owner", "username"},
73 "act": {"name", "action"}, "update": {"name", "description", "autostart"},
74 "remove": {"name", "disks"},
75 "library": None, "media": {"name", "image"},
76 "preset": {"name", "id", "os", "description"},
77 "console": {"name"}, "serial": {"name"}, "guest": {"name"}, "owner": {"name"}, "access": {"name"}, "upload": {"volume", "size"},
78}
79
80
81def profile(name):
82 try:
83 return PROFILES[name]
84 except KeyError as error:
85 raise ValueError("This VM needs a supported hardware profile.") from error
86
87
88def default_profile(platform, firmware, arch="x86_64"):
89 if platform == "macos":
90 return "macos-legacy-q35"
91 if arch == "aarch64":
92 return "windows-aarch64-nvme" if platform == "windows" else "linux-aarch64-virtio"
93 if platform == "windows":
94 return "windows-q35-uefi" if firmware == "uefi" else "windows-q35-bios"
95 return "linux-x86-virtio"
96
97
98def installer_profile(source, spec):
99 if source:
100 name = source.name.lower()
101 if "windows xp" in name or "windows vista" in name or "windows 7" in name:
102 return "windows-legacy-ide"
103 if "windows 8" in name:
104 return "windows-q35-bios"
105 if "windows 11" in name and architecture(name) == "x86_64":
106 return "windows-q35-secure"
107 arch = architecture(name)
108 else:
109 arch = "x86_64"
110 return default_profile(spec["platform"], spec["firmware"], arch)
111
112
113def inferred_profile(root):
114 stored = root.findtext(f"metadata/{NS}vm/{NS}hardwareProfile")
115 if stored:
116 return stored
117 os_element = root.find("os")
118 type_element = os_element.find("type") if os_element is not None else None
119 arch = type_element.get("arch", "x86_64") if type_element is not None else "x86_64"
120 machine = type_element.get("machine", "") if type_element is not None else ""
121 platform = root.findtext(f"metadata/{NS}vm/{NS}platform", "linux")
122 firmware = "uefi" if (os_element is not None and (os_element.find("loader") is not None or os_element.get("firmware") == "efi")) else "bios"
123 if machine.startswith("pc-i440fx"):
124 return "windows-legacy-ide"
125 return default_profile(platform, firmware, arch)
126
127
128def checked_profile(name, platform, firmware, arch):
129 result = profile(name)
130 if (result["platform"] != platform or result["arch"] != arch
131 or (result["firmware"] and result["firmware"] != firmware)):
132 raise ValueError("This preset needs to be prepared again.")
133 return result
134
135
136def node():
137 memory = next(int(line.split()[1]) * 1024 for line in Path("/proc/meminfo").read_text().splitlines() if line.startswith("MemTotal:"))
138 return {"cpus": os.cpu_count(), "memory": memory}
139
140
141def validate(action, payload):
142 if action not in ACTION_FIELDS:
143 raise ValueError("Choose a supported VM action.")
144 fields = ACTION_FIELDS[action]
145 if fields is None:
146 if payload is not None:
147 raise ValueError("This VM query doesn't take any fields.")
148 return
149 if (not isinstance(payload, dict) or not set(payload) <= fields
150 or (action != "update" and set(payload) != fields)
151 or (action == "update" and ("name" not in payload or len(payload) < 2))):
152 raise ValueError("Use only the fields required by this VM action.")
153 if action == "upload":
154 volume = payload["volume"]
155 if (not isinstance(volume, str) or not volume.lower().endswith(".iso")
156 or len(volume) > 200 or not re.fullmatch(r"[a-zA-Z0-9][a-zA-Z0-9 ._()-]*", volume)):
157 raise ValueError("Choose an ISO with a simple filename ending in .iso.")
158 if type(payload["size"]) is not int or not 32768 <= payload["size"] <= 32 * 2**30:
159 raise ValueError("Choose an ISO between 32 KiB and 32 GiB.")
160 return
161 if "name" not in payload:
162 raise ValueError("Enter a VM name.")
163 ensure_name(payload["name"])
164 if "description" in payload and (not isinstance(payload["description"], str) or len(payload["description"]) > 200):
165 raise ValueError("Keep the description under 200 characters.")
166 for field in ("autostart", "start", "disks"):
167 if field in payload and not isinstance(payload[field], bool):
168 raise ValueError(f"Choose whether to enable {field}.")
169 if action == "act" and (not isinstance(payload["action"], str) or payload["action"] not in {"start", "shutdown", "reboot", "destroy", "resume"}):
170 raise ValueError("Choose a supported VM action.")
171 if action == "preset":
172 ensure_name(payload["id"])
173 if not isinstance(payload["os"], str) or not 1 <= len(payload["os"]) <= 100:
174 raise ValueError("Enter the operating system's name.")
175 if action == "media":
176 validate_image_id(payload["image"], empty=True)
177 if action == "create":
178 if not isinstance(payload["owner"], str) or not re.fullmatch(r"[a-zA-Z0-9_-]{1,128}", payload["owner"]):
179 raise ValueError("Choose a signed-in account to own this VM.")
180 if not isinstance(payload["username"], str) or not 1 <= len(payload["username"]) <= 128:
181 raise ValueError("Choose a signed-in account to create this VM.")
182 if payload["mode"] not in {"iso", "preset"}:
183 raise ValueError("Choose an installer or a prepared preset.")
184 if payload["firmware"] not in {"bios", "uefi"} or payload["platform"] not in {"linux", "windows", "macos"}:
185 raise ValueError("Choose supported firmware and guest hardware.")
186 if payload["platform"] == "macos" and payload["mode"] != "preset":
187 raise ValueError("Use a prepared OS X preset.")
188 image = payload["image"]
189 validate_image_id(image)
190 host = node()
191 for field, minimum, maximum in [("vcpus", 1, host["cpus"]), ("memory", 2**29, host["memory"]), ("disk", 2**30, 2**63 - 1)]:
192 if type(payload[field]) is not int or not minimum <= payload[field] <= maximum:
193 raise ValueError(f"Choose {field} within the host's supported range.")
194 if DISKS.is_symlink() or (DISKS / payload["name"]).is_symlink():
195 raise ValueError("The VM disk directory is a symbolic link. Remove the link before continuing.")
196
197
198def command(*args, pass_fds=()):
199 return subprocess.run(args, check=True, text=True, capture_output=True, pass_fds=pass_fds).stdout.strip()
200
201
202def virsh(*args):
203 return command("virsh", "-c", "qemu:///system", *args)
204
205
206def info(file):
207 return json.loads(command("qemu-img", "info", "-U", "--output=json", str(file)))
208
209
210def standalone(details):
211 return (details.get("format") in {"raw", "qcow2"} and not details.get("backing-filename")
212 and not details.get("format-specific", {}).get("data", {}).get("data-file"))
213
214
215def disk(file, target, pool):
216 details = info(file) if file.is_file() else {}
217 return {
218 "target": target,
219 "pool": pool,
220 "source": file.name if pool else str(file),
221 "capacity": details.get("virtual-size", file.stat().st_size if file.exists() else 0),
222 "allocation": details.get("actual-size", 0),
223 }
224
225
226def image(file):
227 os_name = file.stem.replace("_", " ").replace("-", " ")
228 windows = "windows" in os_name.lower()
229 return {"volume": image_id(file), "os": os_name, "arch": architecture(file.name),
230 "capacity": file.stat().st_size,
231 "recommended": {"vcpus": 4 if windows else 2, "memory": 4 * 2**30,
232 "disk": (64 if windows else 32) * 2**30}}
233
234
235def domains():
236 result = []
237 for name in virsh("list", "--all", "--name").splitlines():
238 if not name:
239 continue
240 root = ET.fromstring(virsh("dumpxml", name))
241 state_line = virsh("domstate", name, "--reason").splitlines()[0].lower()
242 state = next((value for value in ("running", "blocked", "paused", "shutdown", "crashed", "pmsuspended") if state_line.startswith(value)), "shutoff")
243 reason = state_line.split("(", 1)[1].rstrip(")") if state in {"paused", "crashed"} and "(" in state_line else None
244 memory = int(root.findtext("memory", "0")) * 1024
245 balloon = int(root.findtext("currentMemory", str(memory // 1024))) * 1024
246 disks = []
247 media = []
248 for element in root.findall("./devices/disk"):
249 if element.findtext("serial") == "studio-cloud-init":
250 continue
251 source = element.find("source")
252 target = element.find("target")
253 if target is not None and element.get("device") == "cdrom":
254 media.append({"target": target.get("dev", ""),
255 "source": Path(source.get("file")).name if source is not None and source.get("file") else None})
256 continue
257 if source is None or target is None:
258 continue
259 file = source.get("file") or source.get("dev")
260 if not file:
261 continue
262 target_name = target.get("dev", "")
263 pool = "vms" if Path(file).is_relative_to(DISKS / name) else None
264 disks.append(disk(Path(file), target_name, pool))
265 interfaces = []
266 for element in root.findall("./devices/interface"):
267 mac = element.find("mac")
268 source = element.find("source")
269 interfaces.append({
270 "mac": mac.get("address", "") if mac is not None else "",
271 "source": source.get("network", source.get("bridge", "")) if source is not None else "",
272 "addresses": [],
273 })
274 if state == "running":
275 for line in virsh("domifaddr", name, "--source", "lease").splitlines():
276 fields = line.split()
277 if len(fields) >= 4:
278 interface = next((item for item in interfaces if item["mac"].lower() == fields[1].lower()), None)
279 if interface:
280 interface["addresses"].append(fields[3].split("/", 1)[0])
281 pid_file = Path("/run/libvirt/qemu") / f"{name}.pid"
282 started = None
283 if state == "running" and pid_file.exists():
284 pid = pid_file.read_text().strip()
285 boot = next(int(line.split()[1]) for line in Path("/proc/stat").read_text().splitlines() if line.startswith("btime "))
286 ticks = int(Path(f"/proc/{pid}/stat").read_text().rsplit(") ", 1)[1].split()[19])
287 started = boot + ticks / os.sysconf("SC_CLK_TCK")
288 agent = root.find("./devices/channel/target[@name='org.qemu.guest_agent.0']")
289 setup = root.findtext(f"metadata/{NS}vm/{NS}setup")
290 result.append({
291 "name": name,
292 "owner": root.findtext(f"metadata/{NS}vm/{NS}owner"),
293 "description": root.findtext("description", ""),
294 "state": state,
295 "reason": reason,
296 "os": root.findtext(f"metadata/{NS}vm/{NS}os", root.findtext(f"metadata/{NS}os", "Other")),
297 "vcpus": int(root.findtext("vcpu", "1")),
298 "pinned": None,
299 "memory": memory,
300 "balloon": balloon,
301 "autostart": re.search(r"^Autostart:\s+enable", virsh("dominfo", name), re.MULTILINE) is not None,
302 "startedAt": started,
303 "agent": agent.get("state", "disconnected") if agent is not None else None,
304 "setup": windows_setup(name) if setup == "windows-v3" and state == "running" else None,
305 "disks": disks,
306 "media": media,
307 "firmware": "uefi" if root.find("./os/loader") is not None or root.find("os").get("firmware") == "efi" else "bios",
308 "platform": root.findtext(f"metadata/{NS}vm/{NS}platform", "linux"),
309 "console": root.find("./devices/graphics[@type='vnc']") is not None,
310 "serial": root.find("./devices/console[@type='pty']/target[@type='serial']") is not None,
311 "interfaces": interfaces,
312 "hostdevs": [],
313 })
314 return result
315
316
317def windows_setup(name):
318 for stage in ["ready", "failed", "restarting", "personalizing"]:
319 try:
320 handle = json.loads(virsh("qemu-agent-command", name, json.dumps({"execute": "guest-file-open", "arguments": {"path": "C:\\Snowglobe\\setup\\" + stage, "mode": "r"}}), "--timeout", "1"))["return"]
321 except (subprocess.CalledProcessError, ValueError, KeyError, TypeError):
322 continue
323 try:
324 virsh("qemu-agent-command", name, json.dumps({"execute": "guest-file-close", "arguments": {"handle": handle}}), "--timeout", "1")
325 except subprocess.CalledProcessError:
326 pass
327 return stage
328 return "starting"
329
330
331def ensure_name(name):
332 if not isinstance(name, str) or not NAME.fullmatch(name):
333 raise ValueError("invalid VM name")
334
335
336def ensure_network():
337 if re.search(r"^Active:\s+no", virsh("net-info", "default"), re.MULTILINE):
338 virsh("net-start", "default")
339 virsh("net-autostart", "default")
340
341
342def validate_image_id(value, empty=False):
343 if not isinstance(value, str) or len(value) > 512 or (not value and not empty):
344 raise ValueError("Choose an installer from the library.")
345 if value in {"", "blank"}:
346 return
347 relative = value.split(":", 1)[1] if value.startswith(("media:", "upload:")) else value
348 if relative.startswith("/") or any(part in {"", ".", ".."} for part in relative.split("/")):
349 raise ValueError("Choose an installer from the library.")
350
351
352def open_regular(file):
353 # Walk with directory FDs so replacing any parent with a symlink cannot escape.
354 parent = os.open("/", os.O_RDONLY | os.O_DIRECTORY)
355 try:
356 for part in file.parts[1:-1]:
357 child = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=parent)
358 os.close(parent)
359 parent = child
360 fd = os.open(file.name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK, dir_fd=parent)
361 if not stat.S_ISREG(os.fstat(fd).st_mode):
362 os.close(fd)
363 raise ValueError("Choose a regular image file.")
364 return fd
365 finally:
366 os.close(parent)
367
368
369def resolve_image(value):
370 validate_image_id(value)
371 if value.startswith("upload:"):
372 file = UPLOADS / value.removeprefix("upload:")
373 else:
374 file = IMAGES / value.removeprefix("media:")
375 if file.suffix.lower() != ".iso":
376 raise ValueError("Choose a supported installer or disk image.")
377 try:
378 os.close(open_regular(file))
379 except OSError as error:
380 raise ValueError("This image is unavailable. Refresh the library and choose another.") from error
381 return file
382
383
384def architecture(name):
385 text = name.lower()
386 return "aarch64" if any(word in text for word in ("arm64", "aarch64")) else "x86_64"
387
388
389def read_preset(identity):
390 ensure_name(identity)
391 directory = PRESETS / identity
392 try:
393 with os.fdopen(open_regular(directory / "preset.json")) as incoming:
394 result = json.load(incoming)
395 with os.fdopen(open_regular(directory / "disk.qcow2"), "rb") as incoming:
396 details = json.loads(command("qemu-img", "info", "-U", "--output=json", f"/proc/self/fd/{incoming.fileno()}", pass_fds=(incoming.fileno(),)))
397 if not standalone(details) or result["firmware"] not in {"bios", "uefi"} or result["platform"] not in {"linux", "windows", "macos"}:
398 raise ValueError("This preset needs to be prepared again.")
399 if "supersedes" in result:
400 ensure_name(result["supersedes"])
401 result.setdefault("arch", "x86_64")
402 result.setdefault("hardwareProfile", default_profile(result["platform"], result["firmware"], result["arch"]))
403 checked_profile(result["hardwareProfile"], result["platform"], result["firmware"], result["arch"])
404 result["capacity"] = details["virtual-size"]
405 return result
406 except (OSError, KeyError, json.JSONDecodeError) as error:
407 raise ValueError("This preset is unavailable. Refresh the library and choose another.") from error
408
409
410def image_id(file):
411 if file.is_relative_to(IMAGES):
412 return "media:" + file.relative_to(IMAGES).as_posix()
413 return "upload:" + file.relative_to(UPLOADS).as_posix()
414
415
416def library():
417 installers = []
418 roots = [IMAGES]
419 if not UPLOADS.is_relative_to(IMAGES):
420 roots.append(UPLOADS)
421 for base in roots:
422 if not base.is_dir() or base.is_symlink():
423 continue
424 for root, directories, files in os.walk(base, followlinks=False):
425 directories[:] = [name for name in directories if not (Path(root) / name).is_symlink() and not name.startswith(".") and name != "Presets"]
426 for name in files:
427 file = Path(root) / name
428 if file.is_symlink() or file.suffix.lower() != ".iso":
429 continue
430 installers.append(image(file))
431 presets = []
432 directory = PRESETS
433 if directory.is_dir() and not directory.is_symlink():
434 for file in sorted(directory.iterdir()):
435 if not file.is_dir() or file.is_symlink() or not NAME.fullmatch(file.name):
436 continue
437 try:
438 presets.append(read_preset(file.name))
439 except (ValueError, subprocess.CalledProcessError):
440 continue
441 superseded = {item.get("supersedes") for item in presets}
442 return {"installers": sorted(installers, key=lambda item: item["os"].lower()), "presets": [item for item in presets if item["id"] not in superseded], "arch": "x86_64"}
443
444
445def managed_directory(path):
446 path.mkdir(parents=True, exist_ok=True)
447 fd = os.open("/", os.O_RDONLY | os.O_DIRECTORY)
448 try:
449 for part in path.parts[1:]:
450 child = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=fd)
451 os.close(fd)
452 fd = child
453 finally:
454 os.close(fd)
455
456
457def change_media(spec):
458 name = spec["name"]
459 root = ET.fromstring(virsh("dumpxml", name, "--inactive"))
460 drives = root.findall("./devices/disk[@device='cdrom']")
461 source = resolve_image(spec["image"]) if spec["image"] else None
462 details = profile(inferred_profile(root))
463 if source and architecture(source.name) != details["arch"]:
464 raise ValueError(f"Choose a {details['arch']} ISO for this VM.")
465 directory = DISKS / name
466 managed_directory(directory)
467 frozen = None
468 if source:
469 fd, temporary = tempfile.mkstemp(prefix=source.stem[:70] + "-", suffix=".iso", dir=directory)
470 frozen = Path(temporary)
471 try:
472 with os.fdopen(open_regular(source), "rb") as incoming, os.fdopen(fd, "wb") as outgoing:
473 shutil.copyfileobj(incoming, outgoing)
474 except BaseException:
475 frozen.unlink(missing_ok=True)
476 raise
477 cd = drives[0] if drives else ET.Element("disk", type="file", device="cdrom")
478 target = cd.find("target")
479 if target is None:
480 ET.SubElement(cd, "driver", name="qemu", type="raw")
481 ET.SubElement(cd, "target", dev=details["cd"][0], bus=details["cd"][1])
482 ET.SubElement(cd, "readonly")
483 for element in cd.findall("source"):
484 cd.remove(element)
485 if frozen:
486 ET.SubElement(cd, "source", file=str(frozen))
487 flags = ["--config"]
488 if virsh("domstate", name).strip() != "shut off":
489 if not drives:
490 if frozen:
491 frozen.unlink(missing_ok=True)
492 raise ValueError("Shut down this VM before adding its first CD drive.")
493 flags.append("--live")
494 xml = directory / "media.xml"
495 xml.write_bytes(ET.tostring(cd))
496 try:
497 virsh("update-device" if drives else "attach-device", name, str(xml), *flags)
498 except BaseException:
499 # A live/config update can partially succeed. Keep media referenced by either XML.
500 if frozen and str(frozen) not in virsh("dumpxml", name) + virsh("dumpxml", name, "--inactive"):
501 frozen.unlink(missing_ok=True)
502 raise
503 # Only collect managed optical images no longer referenced by either definition.
504 references = virsh("dumpxml", name) + virsh("dumpxml", name, "--inactive")
505 for file in directory.glob("*.iso"):
506 if str(file) not in references:
507 file.unlink()
508
509
510def save_preset(spec):
511 name = spec["name"]
512 if virsh("domstate", name).strip() != "shut off":
513 raise ValueError("Shut down this VM before saving a preset.")
514 root = ET.fromstring(virsh("dumpxml", name, "--inactive"))
515 if root.findtext(f"metadata/{NS}vm/{NS}platform") == "macos":
516 raise ValueError("Use a prepared OS X preset from the OS library.")
517 if root.findtext(f"metadata/{NS}vm/{NS}setup") in {"windows-v2", "windows-v3"} and spec.get("setup") != "windows-v3":
518 raise ValueError("Prepare a Windows template before publishing another preset.")
519 disks = root.findall("./devices/disk[@device='disk']")
520 if len(disks) != 1 or root.findall("./devices/hostdev"):
521 raise ValueError("Use a VM with one disk and no passed-through devices.")
522 source = disks[0].find("source")
523 if source is None or not source.get("file"):
524 raise ValueError("Use a VM with a managed disk.")
525 file = Path(source.get("file"))
526 if not file.is_relative_to(DISKS / name):
527 raise ValueError("Use a disk stored with this VM.")
528 base = PRESETS
529 managed_directory(base)
530 target = base / spec["id"]
531 if target.exists():
532 raise ValueError("A preset already has this name. Choose another name.")
533 temporary = Path(tempfile.mkdtemp(prefix=".preparing-", dir=base))
534 try:
535 # Copy from a verified FD; never follow a swapped symlink to a host file.
536 with os.fdopen(open_regular(file), "rb") as incoming:
537 command("cp", "--reflink=auto", "--sparse=always", f"/proc/self/fd/{incoming.fileno()}", str(temporary / "source"), pass_fds=(incoming.fileno(),))
538 details = info(temporary / "source")
539 if not standalone(details):
540 backing = Path(details.get("backing-filename", ""))
541 if (details.get("format") != "qcow2" or details.get("format-specific", {}).get("data", {}).get("data-file")
542 or backing.parent.parent != PRESETS or backing.name != "disk.qcow2"):
543 raise ValueError("Use a standalone disk or an overlay of a prepared preset.")
544 read_preset(backing.parent.name)
545 command("qemu-img", "convert", "-O", "qcow2", str(temporary / "source"), str(temporary / "disk.qcow2"))
546 (temporary / "source").unlink()
547 os_element = root.find("os")
548 firmware = "uefi" if root.find("./os/loader") is not None or os_element.get("firmware") == "efi" else "bios"
549 platform = root.findtext(f"metadata/{NS}vm/{NS}platform", "linux")
550 hardware_profile = inferred_profile(root)
551 type_element = os_element.find("type")
552 arch = type_element.get("arch", "x86_64")
553 details_profile = checked_profile(hardware_profile, platform, firmware, arch)
554 # Persistent firmware variables are needed for installers that only register an EFI boot entry.
555 nvram = root.findtext("./os/nvram")
556 if nvram and not details_profile.get("secure"):
557 with os.fdopen(open_regular(Path(nvram)), "rb") as incoming, (temporary / "nvram.fd").open("xb") as outgoing:
558 shutil.copyfileobj(incoming, outgoing)
559 preset = {"id": spec["id"], "os": spec["os"], "description": spec["description"],
560 "firmware": firmware, "platform": platform, "arch": arch, "hardwareProfile": hardware_profile,
561 "capacity": details["virtual-size"], "createdAt": int(time.time()),
562 "recommended": {"vcpus": int(root.findtext("vcpu", "2")),
563 "memory": int(root.findtext("memory")) * 1024, "disk": details["virtual-size"]}}
564 if spec.get("setup") == "windows-v3" and platform == "windows":
565 preset["setup"] = "windows-v3"
566 if supersedes := spec.get("supersedes"):
567 ensure_name(supersedes)
568 preset["supersedes"] = supersedes
569 (temporary / "preset.json").write_text(json.dumps(preset))
570 # rename won't overwrite a nonempty preset, including a concurrent publication.
571 temporary.rename(target)
572 finally:
573 if temporary.exists():
574 shutil.rmtree(temporary)
575
576
577def console_target(name):
578 root = ET.fromstring(virsh("dumpxml", name))
579 graphics = root.find("./devices/graphics[@type='vnc']")
580 if virsh("domstate", name).strip() not in {"running", "paused", "blocked"} or graphics is None:
581 raise ValueError("Start this VM before opening its screen.")
582 # Only libvirt's loopback VNC listener; callers cannot provide a host or port.
583 if graphics.get("listen") != "127.0.0.1" or graphics.get("socket"):
584 raise ValueError("Configure this VM's VNC screen to listen on 127.0.0.1.")
585 port = int(graphics.get("port", "-1"))
586 if not 5900 <= port <= 65535:
587 raise ValueError("This VM's screen is not ready. Try again shortly.")
588 return {"port": port, "uuid": root.findtext("uuid")}
589
590
591def secure_firmware(arch):
592 firmware = "AAVMF" if arch == "aarch64" else "OVMF"
593 code = Path(os.environ.get(f"STUDIO_VM_SECURE_{firmware}_CODE", ""))
594 variables = Path(os.environ.get(f"STUDIO_VM_SECURE_{firmware}_VARS", ""))
595 if not code.is_absolute() or not variables.is_absolute():
596 raise ValueError("Secure UEFI firmware is unavailable on this host.")
597 try:
598 for file in [code, variables]:
599 os.close(open_regular(file))
600 except OSError as error:
601 raise ValueError("Secure UEFI firmware is unavailable on this host.") from error
602 return code, variables
603
604
605def copy_regular(source, target):
606 with os.fdopen(open_regular(source), "rb") as incoming, target.open("xb") as outgoing:
607 shutil.copyfileobj(incoming, outgoing)
608
609
610def guest_access(directory, username, hostname):
611 password = secrets.token_urlsafe(18)
612 with open(directory / "access.json", "x", opener=lambda path, flags: os.open(path, flags, 0o600)) as outgoing:
613 json.dump({"username": username, "password": password, "hostname": hostname}, outgoing)
614 return password
615
616
617def linux_seed(directory, username, os_name, serial="ttyS0"):
618 if not re.fullmatch(r"[a-z_][a-z0-9_-]{0,31}", username) or username == "root":
619 raise ValueError("Use an account username suitable for a Linux guest.")
620 hostname = "snowglobe-vm-" + secrets.token_hex(4)
621 password = guest_access(directory, username, hostname)
622 hashed = subprocess.run(["openssl", "passwd", "-6", "-stdin"], input=password + "\n", text=True, capture_output=True, check=True).stdout.strip()
623 fedora = "fedora" in os_name.lower()
624 nixos = "nixos" in os_name.lower()
625 account_tool = "/run/current-system/sw/bin/" if nixos else ""
626 rename = f'''set -eu
627old=$({account_tool}getent passwd 1000 | cut -d: -f1)
628test -n "$old"
629if [ "$old" != {username} ]; then
630 ! {account_tool}getent passwd {username}
631 {account_tool}usermod -l {username} -c {username} -d /home/{username} -m "$old"
632 if [ "$({account_tool}getent group 1000 | cut -d: -f1)" = "$old" ]; then {account_tool}groupmod -n {username} "$old"; fi
633fi
634'''
635 gdm_config = "/etc/gdm/custom.conf" if fedora or nixos else "/etc/gdm3/custom.conf"
636 files = [
637 {"path": gdm_config, "content": f"[daemon]\nAutomaticLoginEnable=true\nAutomaticLogin={username}\n"},
638 ]
639 if not nixos:
640 files.insert(0, {"path": "/etc/ssh/sshd_config.d/00-snowglobe.conf", "permissions": "0600", "content": "PasswordAuthentication yes\nPermitRootLogin yes\n"})
641 else:
642 files.append({"path": "/var/lib/snowglobe/hostname", "content": hostname + "\n"})
643 config = {
644 "users": [], "disable_root": False, "ssh_pwauth": True, "ssh_deletekeys": True,
645 "bootcmd": ([["rm", "-f", gdm_config]] if nixos else []) + [["sh", "-c", rename]],
646 "chpasswd": {"expire": False, "users": [{"name": user, "password": hashed, "type": "hash"} for user in [username, "root"]]},
647 "write_files": files,
648 "runcmd": [["systemctl", "enable", "--now", "sshd" if fedora or nixos else "ssh"], ["systemctl", "enable", "--now", f"serial-getty@{serial}.service"]],
649 }
650 with tempfile.TemporaryDirectory(prefix=".seed-", dir=directory) as temporary:
651 files = Path(temporary)
652 (files / "user-data").write_text("#cloud-config\n" + json.dumps(config))
653 (files / "meta-data").write_text(json.dumps({"instance-id": secrets.token_hex(16), "local-hostname": hostname}))
654 command("genisoimage", "-quiet", "-output", str(directory / "seed.iso"), "-volid", "cidata", "-joliet", "-rock", str(files / "user-data"), str(files / "meta-data"))
655
656
657def windows_seed(directory, username):
658 if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,19}", username):
659 raise ValueError("Use an account username suitable for a Windows guest.")
660 hostname = "sgvm-" + secrets.token_hex(4)
661 password = guest_access(directory, username, hostname)
662 with tempfile.TemporaryDirectory(prefix=".seed-", dir=directory) as temporary:
663 marker = Path(temporary) / "SNOWGLOB.INI"
664 marker.write_text(f"[snowglobe]\nUSERNAME={username}\nPASSWORD={password}\nHOSTNAME={hostname}\n")
665 command("genisoimage", "-quiet", "-output", str(directory / "seed.iso"), "-volid", "SNOWGLOBE", "-joliet", "-rock", str(marker))
666
667
668def macos_seed(directory, username):
669 if not re.fullmatch(r"[a-z_][a-z0-9_-]{0,31}", username) or username == "root":
670 raise ValueError("Use an account username suitable for an OS X guest.")
671 hostname = "snowglobe-vm-" + secrets.token_hex(4)
672 password = guest_access(directory, username, hostname)
673 command("ssh-keygen", "-q", "-t", "rsa", "-b", "3072", "-m", "PEM", "-N", "", "-f", str(directory / "ssh_key"))
674 with tempfile.TemporaryDirectory(prefix=".seed-", dir=directory) as temporary:
675 files = Path(temporary)
676 (files / "access.json").write_text(json.dumps({"username": username, "password": password, "hostname": hostname}))
677 (files / "authorized_keys").write_text('from="10.0.2.2",no-agent-forwarding,no-port-forwarding,no-X11-forwarding ' + (directory / "ssh_key.pub").read_text())
678 command("ssh-keygen", "-q", "-t", "rsa", "-b", "3072", "-m", "PEM", "-N", "", "-f", str(files / "ssh_host_rsa_key"))
679 (directory / "ssh_known_hosts").write_text(directory.name + " " + (files / "ssh_host_rsa_key.pub").read_text())
680 command("genisoimage", "-quiet", "-output", str(directory / "seed.iso"), "-volid", "SNOWGLOBE", "-joliet", "-rock", str(files))
681 (directory / "ssh_key.pub").unlink()
682 (directory / "seed.iso").chmod(0o600)
683
684
685def create(spec):
686 name = spec["name"]
687 ensure_name(name)
688 if name in virsh("list", "--all", "--name").splitlines():
689 raise ValueError("VM already exists")
690 selected = spec["image"]
691 mode = spec["mode"]
692 preset = None
693 if mode == "preset":
694 ensure_name(selected)
695 preset = read_preset(selected)
696 source = PRESETS / selected / "disk.qcow2"
697 profile_name = preset["hardwareProfile"]
698 spec = {**spec, "firmware": preset["firmware"], "platform": preset["platform"]}
699 else:
700 source = resolve_image(selected) if selected != "blank" else None
701 profile_name = installer_profile(source, spec)
702 details_profile = profile(profile_name)
703 spec = {**spec, "firmware": details_profile["firmware"] or spec["firmware"], "platform": details_profile["platform"]}
704 if spec["platform"] == "macos" and spec["vcpus"] != 1:
705 raise ValueError("Use one vCPU for this OS X preset.")
706 if not preset and source and architecture(source.name) != details_profile["arch"]:
707 raise ValueError(f"Choose a {details_profile['arch']} ISO for this hardware profile.")
708 if mode == "preset" and spec["disk"] < preset["capacity"]:
709 raise ValueError("Choose a disk at least as large as the preset.")
710 if "vms" not in virsh("pool-list", "--all", "--name").splitlines():
711 DISKS.mkdir(parents=True, exist_ok=True)
712 virsh("pool-define-as", "vms", "dir", "--target", str(DISKS))
713 if re.search(r"^State:\s+inactive", virsh("pool-info", "vms"), re.MULTILINE):
714 virsh("pool-start", "vms")
715 virsh("pool-autostart", "vms")
716 directory = DISKS / name
717 directory.mkdir(mode=0o700, parents=True, exist_ok=False)
718 drive = directory / "disk.qcow2"
719
720 def expired(_signum, _frame):
721 raise TimeoutError("VM image preparation timed out.")
722
723 previous = signal.signal(signal.SIGALRM, expired)
724 signal.alarm(840)
725 try:
726 os_name = "Other"
727 overlay = preset and not (spec["platform"] == "macos" and spec["disk"] > preset["capacity"])
728 if overlay:
729 os_name = preset["os"]
730 with os.fdopen(open_regular(source), "rb") as incoming:
731 details = json.loads(command("qemu-img", "info", "-U", "--output=json", f"/proc/self/fd/{incoming.fileno()}", pass_fds=(incoming.fileno(),)))
732 if not standalone(details):
733 raise ValueError("This preset needs to be prepared again.")
734 command("qemu-img", "create", "-u", "-f", "qcow2", "-F", details["format"], "-b", str(source), str(drive), str(spec["disk"]))
735 elif source:
736 source_fd = open_regular(source)
737 frozen = directory / (source.name if source.suffix.lower() == ".iso" else "source-image")
738 with os.fdopen(source_fd, "rb") as incoming:
739 if source.suffix.lower() == ".iso":
740 with frozen.open("xb") as outgoing:
741 shutil.copyfileobj(incoming, outgoing)
742 else:
743 command("cp", "--reflink=auto", "--sparse=always", f"/proc/self/fd/{incoming.fileno()}", str(frozen), pass_fds=(incoming.fileno(),))
744 details = info(frozen) if source.suffix.lower() != ".iso" else {}
745 if source.suffix.lower() != ".iso" and not standalone(details):
746 raise ValueError("Use a standalone raw or QCOW2 image without external data files.")
747 os_name = preset["os"] if preset else source.stem.replace("_", " ").replace("-", " ")
748 if details.get("virtual-size", 0) > spec["disk"]:
749 raise ValueError("Choose a disk at least as large as the OS image.")
750 source = frozen
751 if not overlay and source and source.suffix.lower() != ".iso":
752 if spec["platform"] == "macos" and spec["disk"] > details["virtual-size"]:
753 expanded = directory / "expanded.raw"
754 command("qemu-img", "convert", "-O", "raw", str(source), str(expanded))
755 command("qemu-img", "resize", "-f", "raw", str(expanded), str(spec["disk"]))
756 command("sgdisk", "--move-second-header", str(expanded))
757 command("qemu-img", "convert", "-O", "qcow2", str(expanded), str(drive))
758 expanded.unlink()
759 else:
760 command("qemu-img", "convert", "-O", "qcow2", str(source), str(drive))
761 if spec["disk"] > info(drive)["virtual-size"]:
762 command("qemu-img", "resize", str(drive), str(spec["disk"]))
763 source.unlink()
764 elif not overlay:
765 command("qemu-img", "create", "-f", "qcow2", str(drive), str(spec["disk"]))
766 virsh("pool-refresh", "vms")
767 domain_type = "qemu" if details_profile.get("emulated") or os.environ.get("STUDIO_VM_ACCEL") == "qemu" else "kvm"
768 root = ET.Element("domain", type=domain_type)
769 ET.SubElement(root, "name").text = name
770 ET.SubElement(root, "description").text = spec["description"]
771 metadata = ET.SubElement(ET.SubElement(root, "metadata"), NS + "vm")
772 ET.SubElement(metadata, NS + "os").text = os_name
773 ET.SubElement(metadata, NS + "platform").text = spec["platform"]
774 ET.SubElement(metadata, NS + "hardwareProfile").text = profile_name
775 ET.SubElement(metadata, NS + "owner").text = spec["owner"]
776 if preset and preset.get("setup") == "windows-v3":
777 ET.SubElement(metadata, NS + "setup").text = "windows-v3"
778 ET.SubElement(root, "memory", unit="bytes").text = str(spec["memory"])
779 ET.SubElement(root, "vcpu").text = str(spec["vcpus"])
780 firmware = spec["firmware"]
781 os_element = ET.SubElement(root, "os")
782 if firmware == "uefi":
783 if details_profile.get("secure"):
784 code, variables = secure_firmware(details_profile["arch"])
785 nvram = directory / "nvram.fd"
786 copy_regular(variables, nvram)
787 loader = ET.SubElement(os_element, "loader", readonly="yes", type="pflash")
788 # Libvirt restricts the secure attribute to q35; AAVMF's enrolled variables enable it on ARM.
789 if details_profile["arch"] == "x86_64":
790 loader.set("secure", "yes")
791 loader.text = str(code)
792 ET.SubElement(os_element, "nvram", template=str(variables), templateFormat="raw", format="raw").text = str(nvram)
793 else:
794 os_element.set("firmware", "efi")
795 if preset and (PRESETS / selected / "nvram.fd").exists():
796 nvram = directory / "nvram.fd"
797 copy_regular(PRESETS / selected / "nvram.fd", nvram)
798 ET.SubElement(os_element, "nvram").text = str(nvram)
799 features = ET.SubElement(os_element, "firmware")
800 ET.SubElement(features, "feature", enabled="no", name="secure-boot")
801 ET.SubElement(os_element, "type", arch=details_profile["arch"], machine=details_profile["machine"]).text = "hvm"
802 ET.SubElement(os_element, "boot", dev="cdrom" if source and source.suffix.lower() == ".iso" else "hd")
803 if source and source.suffix.lower() == ".iso":
804 ET.SubElement(os_element, "boot", dev="hd")
805 if not preset:
806 ET.SubElement(os_element, "bootmenu", enable="yes", timeout="5000")
807 features = ET.SubElement(root, "features")
808 ET.SubElement(features, "acpi")
809 if details_profile["arch"] == "x86_64":
810 ET.SubElement(features, "apic")
811 if profile_name == "windows-q35-uefi":
812 hyperv = ET.SubElement(features, "hyperv", mode="custom")
813 for feature in ["relaxed", "vapic", "vpindex", "synic", "stimer", "frequencies", "tlbflush", "ipi"]:
814 ET.SubElement(hyperv, feature, state="on")
815 ET.SubElement(hyperv, "spinlocks", state="on", retries="8191")
816 if gic := details_profile.get("gic"):
817 ET.SubElement(features, "gic", version=gic)
818 if details_profile.get("smm"):
819 ET.SubElement(features, "smm", state="on")
820 if cpu_mode := details_profile.get("cpu_mode"):
821 ET.SubElement(root, "cpu", mode=cpu_mode, check="none", migratable="on")
822 elif details_profile.get("cpu"):
823 cpu = ET.SubElement(root, "cpu", mode="custom", match="exact", check="full")
824 ET.SubElement(cpu, "model", fallback="forbid").text = details_profile["cpu"]
825 ET.SubElement(cpu, "feature", policy="require", name="hypervisor")
826 elif root.get("type") == "qemu":
827 ET.SubElement(root, "cpu", mode="maximum")
828 if profile_name == "windows-q35-uefi":
829 ET.SubElement(root.find("cpu"), "topology", sockets="1", cores=str(spec["vcpus"]), threads="1")
830 clock = ET.SubElement(root, "clock", offset=details_profile["clock"])
831 if profile_name == "windows-q35-uefi":
832 ET.SubElement(clock, "timer", name="hypervclock", present="yes")
833 ET.SubElement(root, "on_poweroff").text = "destroy"
834 ET.SubElement(root, "on_reboot").text = "restart"
835 ET.SubElement(root, "on_crash").text = "destroy"
836 devices = ET.SubElement(root, "devices")
837 emulator = f"/run/current-system/sw/bin/qemu-system-{details_profile['arch']}"
838 if profile_name == "windows-aarch64-nvme":
839 emulator = os.environ.get("STUDIO_VM_WINDOWS_ARM_EMULATOR", "")
840 if not Path(emulator).is_absolute():
841 raise ValueError("Windows ARM is unavailable on this host. Choose another OS.")
842 ET.SubElement(devices, "emulator").text = emulator
843 if details_profile.get("scsi"):
844 ET.SubElement(devices, "controller", type="scsi", index="0", model="virtio-scsi")
845 primary = ET.SubElement(devices, "disk", type="file", device="disk")
846 ET.SubElement(primary, "driver", name="qemu", type="qcow2")
847 ET.SubElement(primary, "source", file=str(drive))
848 ET.SubElement(primary, "target", dev=details_profile["disk"][0], bus=details_profile["disk"][1])
849 if details_profile["disk"][1] == "nvme":
850 ET.SubElement(primary, "serial").text = "snowglobe"
851 cd = ET.SubElement(devices, "disk", type="file", device="cdrom")
852 ET.SubElement(cd, "driver", name="qemu", type="raw")
853 if source and source.suffix.lower() == ".iso":
854 ET.SubElement(cd, "source", file=str(source))
855 ET.SubElement(cd, "target", dev=details_profile["cd"][0], bus=details_profile["cd"][1])
856 ET.SubElement(cd, "readonly")
857 if preset and spec["platform"] == "linux":
858 linux_seed(directory, spec["username"], preset["os"], details_profile.get("serial", "ttyS0"))
859 seed = ET.SubElement(devices, "disk", type="file", device="cdrom")
860 ET.SubElement(seed, "driver", name="qemu", type="raw")
861 ET.SubElement(seed, "source", file=str(directory / "seed.iso"))
862 ET.SubElement(seed, "target", dev=details_profile["seed"][0], bus=details_profile["seed"][1])
863 ET.SubElement(seed, "serial").text = "studio-cloud-init"
864 ET.SubElement(seed, "readonly")
865 if preset and spec["platform"] == "windows":
866 windows_seed(directory, spec["username"])
867 ET.SubElement(cd, "source", file=str(directory / "seed.iso"))
868 ET.SubElement(cd, "serial").text = "snowglobe-provision"
869 if spec["platform"] == "macos":
870 macos_seed(directory, spec["username"])
871 ET.SubElement(cd, "source", file=str(directory / "seed.iso"))
872 qemu_ns = "http://libvirt.org/schemas/domain/qemu/1.0"
873 ET.register_namespace("qemu", qemu_ns)
874 arguments = ET.SubElement(root, "{" + qemu_ns + "}commandline")
875 for argument in [
876 "-device", "isa-applesmc,osk=ourhardworkbythesewordsguardedpleasedontsteal(c)AppleComputerInc",
877 "-smbios", "type=2", "-netdev", "user,id=osxnet,restrict=yes,hostfwd=tcp:127.0.0.1:0-:22",
878 "-device", details_profile["network"] + ",netdev=osxnet,bus=pcie.0,addr=0x12",
879 ]:
880 ET.SubElement(arguments, "{" + qemu_ns + "}arg", value=argument)
881 else:
882 nic = ET.SubElement(devices, "interface", type="network")
883 ET.SubElement(nic, "source", network="default")
884 ET.SubElement(nic, "model", type=details_profile["network"])
885 ET.SubElement(devices, "graphics", type="vnc", port="-1", autoport="yes", listen="127.0.0.1")
886 video = {"type": details_profile.get("video", "vga"), "heads": "1", "primary": "yes"}
887 if video["type"] == "vga":
888 video["vram"] = "16384"
889 ET.SubElement(ET.SubElement(devices, "video"), "model", **video)
890 ET.SubElement(devices, "input", type="tablet", bus="usb")
891 if keyboard := details_profile.get("keyboard"):
892 ET.SubElement(devices, "input", type="keyboard", bus=keyboard)
893 usb = ET.SubElement(devices, "controller", type="usb", index="0", model=details_profile["usb"])
894 if spec["platform"] == "macos":
895 ET.SubElement(usb, "address", type="pci", domain="0x0000", bus="0x00", slot="0x1d", function="0x7")
896 for index in range(3):
897 controller = ET.SubElement(devices, "controller", type="usb", index="0", model=f"ich9-uhci{index + 1}")
898 ET.SubElement(controller, "master", startport=str(index * 2))
899 ET.SubElement(controller, "address", type="pci", domain="0x0000", bus="0x00", slot="0x1d", function=hex(index), **({"multifunction": "on"} if index == 0 else {}))
900 if details_profile.get("tpm"):
901 tpm = ET.SubElement(devices, "tpm", model=details_profile["tpm"])
902 ET.SubElement(tpm, "backend", type="emulator", version="2.0")
903 if details_profile.get("balloon"):
904 ET.SubElement(devices, "memballoon", model=details_profile["balloon"])
905 if spec["platform"] != "macos":
906 ET.SubElement(devices, "console", type="pty")
907 ET.SubElement(devices, "channel", type="unix").append(ET.Element("target", type="virtio", name="org.qemu.guest_agent.0"))
908 ET.SubElement(devices, "channel", type="unix").append(ET.Element("target", type="virtio", name=GUEST_CHANNEL))
909 xml = directory / "domain.xml"
910 xml.write_bytes(ET.tostring(root))
911 signal.alarm(0)
912 virsh("define", str(xml))
913 if spec["autostart"]:
914 virsh("autostart", name)
915 if spec["start"]:
916 if spec["platform"] != "macos":
917 ensure_network()
918 virsh("start", name)
919 except Exception:
920 signal.alarm(0)
921 if name not in virsh("list", "--all", "--name").splitlines():
922 for file in directory.iterdir():
923 file.unlink()
924 directory.rmdir()
925 raise
926 finally:
927 signal.alarm(0)
928 signal.signal(signal.SIGALRM, previous)
929
930
931def main():
932 action = sys.argv[1]
933 payload = json.loads(sys.argv[2]) if len(sys.argv) > 2 else None
934 validate(action, payload)
935 if action == "node":
936 return node()
937 if action == "domains":
938 return domains()
939 if action == "stats":
940 result = {}
941 for name in virsh("list", "--name").splitlines():
942 pid_file = Path("/run/libvirt/qemu") / f"{name}.pid"
943 if not pid_file.exists():
944 continue
945 pid = pid_file.read_text().strip()
946 fields = Path(f"/proc/{pid}/stat").read_text().rsplit(") ", 1)[1].split()
947 resident = int(Path(f"/proc/{pid}/statm").read_text().split()[1]) * os.sysconf("SC_PAGE_SIZE")
948 result[name] = {"cpu": (int(fields[11]) + int(fields[12])) / os.sysconf("SC_CLK_TCK"), "memory": resident,
949 "vcpus": int(ET.fromstring(virsh("dumpxml", name)).findtext("vcpu", "1"))}
950 return result
951 if action == "library":
952 return library()
953 if action == "console":
954 return console_target(payload["name"])
955 if action == "guest":
956 root = ET.fromstring(virsh("dumpxml", payload["name"]))
957 source = root.find(f"./devices/channel[@type='unix']/target[@name='{GUEST_CHANNEL}']/../source")
958 expected = f"/run/libvirt/qemu/channel/{root.get('id')}-{payload['name'][:20]}/{GUEST_CHANNEL}"
959 if virsh("domstate", payload["name"]).strip() != "running" or source is None or source.get("mode") != "bind" or source.get("path") != expected:
960 raise ValueError("This VM has no guest display channel.")
961 return {"path": expected}
962 if action == "serial":
963 if virsh("domstate", payload["name"]).strip() not in {"running", "blocked"}:
964 raise ValueError("Start this VM before opening its console.")
965 root = ET.fromstring(virsh("dumpxml", payload["name"]))
966 console = root.find("./devices/console[@type='pty']")
967 source = console.find("source") if console is not None else None
968 target = console.find("target") if console is not None else None
969 path = source.get("path", "") if source is not None else ""
970 if target is None or target.get("type") != "serial" or not re.fullmatch(r"/dev/pts/[0-9]+", path):
971 raise ValueError("This VM has no serial console. Add one in its hardware settings.")
972 return {"path": path}
973 if action == "owner":
974 return ET.fromstring(virsh("dumpxml", payload["name"])).findtext(f"metadata/{NS}vm/{NS}owner")
975 if action == "access":
976 try:
977 with os.fdopen(open_regular(DISKS / payload["name"] / "access.json")) as incoming:
978 return json.load(incoming)
979 except FileNotFoundError:
980 return None
981 if action == "media":
982 change_media(payload)
983 elif action == "preset":
984 save_preset(payload)
985 elif action == "create":
986 create(payload)
987 elif action == "act":
988 name = payload["name"]
989 ensure_name(name)
990 if payload["action"] == "start":
991 root = ET.fromstring(virsh("dumpxml", name, "--inactive"))
992 if root.find("./devices/interface/source[@network='default']") is not None:
993 ensure_network()
994 virsh({"start": "start", "shutdown": "shutdown", "reboot": "reboot", "destroy": "destroy", "resume": "resume"}[payload["action"]], name)
995 elif action == "update":
996 name = payload["name"]
997 ensure_name(name)
998 if "autostart" in payload:
999 virsh("autostart", *([] if payload["autostart"] else ["--disable"]), name)
1000 if "description" in payload:
1001 flags = ["--config"]
1002 if virsh("domstate", name).strip() != "shut off":
1003 flags.append("--live")
1004 virsh("desc", name, *flags, "--", payload["description"])
1005 elif action == "remove":
1006 name = payload["name"]
1007 ensure_name(name)
1008 if name not in virsh("list", "--all", "--name").splitlines():
1009 raise ValueError("VM does not exist")
1010 if virsh("domstate", name).strip() != "shut off":
1011 virsh("destroy", name)
1012 root = ET.fromstring(virsh("dumpxml", name))
1013 virsh("undefine", name, *(["--nvram"] if root.find("./os/nvram") is not None else []), *(["--tpm"] if root.find("./devices/tpm") is not None else []))
1014 directory = DISKS / name
1015 if payload["disks"] and directory.is_dir():
1016 for file in directory.iterdir():
1017 file.unlink()
1018 directory.rmdir()
1019 else:
1020 raise ValueError("unsupported VM action")
1021 return None
1022
1023
1024if __name__ == "__main__":
1025 try:
1026 print(json.dumps(main()))
1027 except ValueError as failure:
1028 print(str(failure), file=sys.stderr)
1029 sys.exit(2)
1030 except (OSError, subprocess.CalledProcessError) as failure:
1031 print(str(failure), file=sys.stderr)
1032 sys.exit(1)