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