| 1 | #!/usr/bin/env python3 |
| 2 | import json |
| 3 | import os |
| 4 | from pathlib import Path |
| 5 | import re |
| 6 | import secrets |
| 7 | import shutil |
| 8 | import signal |
| 9 | import stat |
| 10 | import tempfile |
| 11 | import time |
| 12 | import subprocess |
| 13 | import sys |
| 14 | import xml.etree.ElementTree as ET |
| 15 | |
| 16 | |
| 17 | DISKS = Path("/srv/vm") |
| 18 | IMAGES = Path(os.environ.get("STUDIO_VM_IMAGES_ROOT", "/srv/clover/Media/vm")) |
| 19 | PRESETS = Path(os.environ.get("STUDIO_VM_PRESETS_ROOT", str(IMAGES / "Presets"))) |
| 20 | UPLOADS = Path(os.environ.get("STUDIO_VM_UPLOADS_ROOT", str(IMAGES / "Install Disks"))) |
| 21 | NS = "{https://paperclover.net/studio}" |
| 22 | NAME = re.compile(r"[a-z0-9][a-z0-9-]{0,62}\Z") |
| 23 | GUEST_CHANNEL = json.loads((Path(__file__).resolve().parent.parent / "guest/protocol.json").read_text())["channel"] |
| 24 | PROFILES = { |
| 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 | } |
| 70 | ACTION_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 | |
| 81 | def 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 | |
| 88 | def 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 | |
| 98 | def 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 | |
| 113 | def 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 | |
| 128 | def 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 | |
| 136 | def 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 | |
| 141 | def 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 | |
| 198 | def command(*args, pass_fds=()): |
| 199 | return subprocess.run(args, check=True, text=True, capture_output=True, pass_fds=pass_fds).stdout.strip() |
| 200 | |
| 201 | |
| 202 | def virsh(*args): |
| 203 | return command("virsh", "-c", "qemu:///system", *args) |
| 204 | |
| 205 | |
| 206 | def info(file): |
| 207 | return json.loads(command("qemu-img", "info", "-U", "--output=json", str(file))) |
| 208 | |
| 209 | |
| 210 | def 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 | |
| 215 | def 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 | |
| 226 | def 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 | |
| 235 | def 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 | |
| 317 | def 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 | |
| 331 | def ensure_name(name): |
| 332 | if not isinstance(name, str) or not NAME.fullmatch(name): |
| 333 | raise ValueError("invalid VM name") |
| 334 | |
| 335 | |
| 336 | def 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 | |
| 342 | def 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 | |
| 352 | def 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 | |
| 369 | def 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 | |
| 384 | def architecture(name): |
| 385 | text = name.lower() |
| 386 | return "aarch64" if any(word in text for word in ("arm64", "aarch64")) else "x86_64" |
| 387 | |
| 388 | |
| 389 | def 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 | |
| 410 | def 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 | |
| 416 | def 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 | |
| 445 | def 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 | |
| 457 | def 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 | |
| 510 | def 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 | |
| 577 | def 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 | |
| 591 | def 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 | |
| 605 | def 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 | |
| 610 | def 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 | |
| 617 | def 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 |
| 627 | old=$({account_tool}getent passwd 1000 | cut -d: -f1) |
| 628 | test -n "$old" |
| 629 | if [ "$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 |
| 633 | fi |
| 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 | |
| 657 | def 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 | |
| 668 | def 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 | |
| 685 | def 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 | |
| 931 | def 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 | |
| 1024 | if __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) |