1#!/usr/bin/env python3
2import json
3import os
4from pathlib import Path
5import re
6import shutil
7import signal
8import stat
9import subprocess
10import sys
11import xml.etree.ElementTree as ET
12
13
14DISKS = Path("/srv/vm")
15IMAGES = Path(os.environ.get("STUDIO_VM_IMAGES_ROOT", "/srv/clover/Media/vm"))
16NAME = re.compile(r"[a-z0-9][a-z0-9-]{0,62}\Z")
17ACTION_FIELDS = {
18 "node": None, "domains": None, "stats": None, "images": None,
19 "create": {"name", "description", "image", "vcpus", "memory", "disk", "autostart", "start"},
20 "act": {"name", "action"}, "update": {"name", "description", "autostart"},
21 "remove": {"name", "disks"},
22}
23
24
25def node():
26 memory = next(int(line.split()[1]) * 1024 for line in Path("/proc/meminfo").read_text().splitlines() if line.startswith("MemTotal:"))
27 return {"cpus": os.cpu_count(), "memory": memory}
28
29
30def validate(action, payload):
31 if action not in ACTION_FIELDS:
32 raise ValueError("Choose a supported VM action.")
33 fields = ACTION_FIELDS[action]
34 if fields is None:
35 if payload is not None:
36 raise ValueError("This VM query doesn't take any fields.")
37 return
38 if (not isinstance(payload, dict) or not set(payload) <= fields
39 or (action != "update" and set(payload) != fields)
40 or (action == "update" and ("name" not in payload or len(payload) < 2))):
41 raise ValueError("Use only the fields required by this VM action.")
42 ensure_name(payload["name"])
43 if "description" in payload and (not isinstance(payload["description"], str) or len(payload["description"]) > 200):
44 raise ValueError("Keep the description under 200 characters.")
45 for field in ("autostart", "start", "disks"):
46 if field in payload and not isinstance(payload[field], bool):
47 raise ValueError(f"Choose whether to enable {field}.")
48 if action == "act" and (not isinstance(payload["action"], str) or payload["action"] not in {"start", "shutdown", "reboot", "destroy", "resume"}):
49 raise ValueError("Choose a supported VM action.")
50 if action == "create":
51 image = payload["image"]
52 if not isinstance(image, str) or not image or len(image) > 255 or image != Path(image).name or image in {".", ".."}:
53 raise ValueError("Choose an OS image from the list.")
54 host = node()
55 for field, minimum, maximum in [("vcpus", 1, host["cpus"]), ("memory", 2**29, host["memory"]), ("disk", 2**30, 2**63 - 1)]:
56 if type(payload[field]) is not int or not minimum <= payload[field] <= maximum:
57 raise ValueError(f"Choose {field} within the host's supported range.")
58 if DISKS.is_symlink() or (DISKS / payload["name"]).is_symlink():
59 raise ValueError("The VM disk directory is a symbolic link. Remove the link before continuing.")
60
61
62def command(*args):
63 return subprocess.run(args, check=True, text=True, capture_output=True).stdout.strip()
64
65
66def virsh(*args):
67 return command("virsh", "-c", "qemu:///system", *args)
68
69
70def info(file):
71 return json.loads(command("qemu-img", "info", "-U", "--output=json", str(file)))
72
73
74def standalone(details):
75 return (details.get("format") in {"raw", "qcow2"} and not details.get("backing-filename")
76 and not details.get("format-specific", {}).get("data", {}).get("data-file"))
77
78
79def disk(file, target, pool):
80 details = info(file) if file.is_file() else {}
81 return {
82 "target": target,
83 "pool": pool,
84 "source": file.name if pool else str(file),
85 "capacity": details.get("virtual-size", file.stat().st_size if file.exists() else 0),
86 "allocation": details.get("actual-size", 0),
87 }
88
89
90def image(file):
91 extension = file.suffix.lower()
92 if extension not in {".iso", ".qcow2", ".img", ".raw"} or not file.is_file():
93 return None
94 os_name = file.stem.replace("_", " ").replace("-", " ")
95 windows = "windows" in os_name.lower() or "win10" in os_name.lower() or "win11" in os_name.lower()
96 details = info(file) if extension != ".iso" else {}
97 if extension != ".iso" and not standalone(details):
98 return None
99 capacity = details.get("virtual-size", file.stat().st_size)
100 return {
101 "volume": file.name,
102 "os": os_name,
103 "kind": "installer" if extension == ".iso" else "disk",
104 "capacity": capacity,
105 "recommended": {
106 "vcpus": 4 if windows else 2,
107 "memory": (4 if windows else 2) * 2**30,
108 "disk": max((64 if windows else 20) * 2**30, capacity),
109 },
110 }
111
112
113def domains():
114 result = []
115 for name in virsh("list", "--all", "--name").splitlines():
116 if not name:
117 continue
118 root = ET.fromstring(virsh("dumpxml", name))
119 state_line = virsh("domstate", name, "--reason").splitlines()[0].lower()
120 state = next((value for value in ("running", "blocked", "paused", "shutdown", "crashed", "pmsuspended") if state_line.startswith(value)), "shutoff")
121 reason = state_line.split("(", 1)[1].rstrip(")") if state in {"paused", "crashed"} and "(" in state_line else None
122 memory = int(root.findtext("memory", "0")) * 1024
123 balloon = int(root.findtext("currentMemory", str(memory // 1024))) * 1024
124 disks = []
125 for element in root.findall("./devices/disk"):
126 source = element.find("source")
127 target = element.find("target")
128 if source is None or target is None:
129 continue
130 file = source.get("file") or source.get("dev")
131 if not file:
132 continue
133 target_name = target.get("dev", "")
134 pool = "vms" if Path(file).is_relative_to(DISKS / name) else None
135 disks.append(disk(Path(file), target_name, pool))
136 interfaces = []
137 for element in root.findall("./devices/interface"):
138 mac = element.find("mac")
139 source = element.find("source")
140 interfaces.append({
141 "mac": mac.get("address", "") if mac is not None else "",
142 "source": source.get("network", source.get("bridge", "")) if source is not None else "",
143 "addresses": [],
144 })
145 if state == "running":
146 for line in virsh("domifaddr", name, "--source", "lease").splitlines():
147 fields = line.split()
148 if len(fields) >= 4:
149 interface = next((item for item in interfaces if item["mac"].lower() == fields[1].lower()), None)
150 if interface:
151 interface["addresses"].append(fields[3].split("/", 1)[0])
152 pid_file = Path("/run/libvirt/qemu") / f"{name}.pid"
153 started = None
154 if state == "running" and pid_file.exists():
155 pid = pid_file.read_text().strip()
156 boot = next(int(line.split()[1]) for line in Path("/proc/stat").read_text().splitlines() if line.startswith("btime "))
157 ticks = int(Path(f"/proc/{pid}/stat").read_text().rsplit(") ", 1)[1].split()[19])
158 started = boot + ticks / os.sysconf("SC_CLK_TCK")
159 result.append({
160 "name": name,
161 "description": root.findtext("description", ""),
162 "state": state,
163 "reason": reason,
164 "os": root.findtext("metadata/{https://paperclover.net/studio}os", "Linux"),
165 "vcpus": int(root.findtext("vcpu", "1")),
166 "pinned": None,
167 "memory": memory,
168 "balloon": balloon,
169 "autostart": re.search(r"^Autostart:\s+enable", virsh("dominfo", name), re.MULTILINE) is not None,
170 "startedAt": started,
171 "agent": None,
172 "disks": disks,
173 "interfaces": interfaces,
174 "hostdevs": [],
175 })
176 return result
177
178
179def ensure_name(name):
180 if not isinstance(name, str) or not NAME.fullmatch(name):
181 raise ValueError("invalid VM name")
182
183
184def ensure_network():
185 if re.search(r"^Active:\s+no", virsh("net-info", "default"), re.MULTILINE):
186 virsh("net-start", "default")
187 virsh("net-autostart", "default")
188
189
190def create(spec):
191 name = spec["name"]
192 ensure_name(name)
193 if name in virsh("list", "--all", "--name").splitlines():
194 raise ValueError("VM already exists")
195 selected = spec["image"]
196 available = {item.name: item for item in IMAGES.iterdir() if item.is_file() and not item.is_symlink()} if IMAGES.is_dir() else {}
197 if selected != "blank" and selected not in available:
198 raise ValueError("OS image is unavailable")
199 source = available.get(selected)
200 if source and source.suffix.lower() not in {".iso", ".qcow2", ".img", ".raw"}:
201 raise ValueError("unsupported OS image")
202 if "vms" not in virsh("pool-list", "--all", "--name").splitlines():
203 DISKS.mkdir(parents=True, exist_ok=True)
204 virsh("pool-define-as", "vms", "dir", "--target", str(DISKS))
205 if re.search(r"^State:\s+inactive", virsh("pool-info", "vms"), re.MULTILINE):
206 virsh("pool-start", "vms")
207 virsh("pool-autostart", "vms")
208 directory = DISKS / name
209 directory.mkdir(parents=True, exist_ok=False)
210 drive = directory / "disk.qcow2"
211
212 def expired(_signum, _frame):
213 raise TimeoutError("VM image preparation timed out.")
214
215 previous = signal.signal(signal.SIGALRM, expired)
216 signal.alarm(50)
217 try:
218 os_name = "Other"
219 if source:
220 directory_fd = os.open("/", os.O_RDONLY | os.O_DIRECTORY)
221 try:
222 for part in IMAGES.parts[1:]:
223 child_fd = os.open(part, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW, dir_fd=directory_fd)
224 os.close(directory_fd)
225 directory_fd = child_fd
226 source_fd = os.open(source.name, os.O_RDONLY | os.O_NOFOLLOW, dir_fd=directory_fd)
227 finally:
228 os.close(directory_fd)
229 frozen = directory / ("installer.iso" if source.suffix.lower() == ".iso" else "source-image")
230 with os.fdopen(source_fd, "rb") as incoming:
231 if not stat.S_ISREG(os.fstat(incoming.fileno()).st_mode):
232 raise ValueError("Choose a regular OS image file.")
233 with frozen.open("xb") as outgoing:
234 shutil.copyfileobj(incoming, outgoing)
235 details = info(frozen) if source.suffix.lower() != ".iso" else {}
236 if source.suffix.lower() != ".iso" and not standalone(details):
237 raise ValueError("Use a standalone raw or QCOW2 image without external data files.")
238 os_name = source.stem.replace("_", " ").replace("-", " ")
239 if details.get("virtual-size", 0) > spec["disk"]:
240 raise ValueError("Choose a disk at least as large as the OS image.")
241 source = frozen
242 if source and source.suffix.lower() != ".iso":
243 command("qemu-img", "convert", "-O", "qcow2", str(source), str(drive))
244 if spec["disk"] > info(drive)["virtual-size"]:
245 command("qemu-img", "resize", str(drive), str(spec["disk"]))
246 source.unlink()
247 else:
248 command("qemu-img", "create", "-f", "qcow2", str(drive), str(spec["disk"]))
249 virsh("pool-refresh", "vms")
250 root = ET.Element("domain", type="qemu" if os.environ.get("STUDIO_VM_ACCEL") == "qemu" else "kvm")
251 ET.SubElement(root, "name").text = name
252 ET.SubElement(root, "description").text = spec["description"]
253 ET.SubElement(ET.SubElement(root, "metadata"), "{https://paperclover.net/studio}os").text = os_name
254 ET.SubElement(root, "memory", unit="bytes").text = str(spec["memory"])
255 ET.SubElement(root, "vcpu").text = str(spec["vcpus"])
256 os_element = ET.SubElement(root, "os")
257 ET.SubElement(os_element, "type", arch="x86_64", machine="q35").text = "hvm"
258 ET.SubElement(os_element, "boot", dev="cdrom" if source and source.suffix.lower() == ".iso" else "hd")
259 devices = ET.SubElement(root, "devices")
260 ET.SubElement(devices, "emulator").text = "/run/current-system/sw/bin/qemu-system-x86_64"
261 primary = ET.SubElement(devices, "disk", type="file", device="disk")
262 ET.SubElement(primary, "driver", name="qemu", type="qcow2")
263 ET.SubElement(primary, "source", file=str(drive))
264 ET.SubElement(primary, "target", dev="vda", bus="virtio")
265 if source and source.suffix.lower() == ".iso":
266 cd = ET.SubElement(devices, "disk", type="file", device="cdrom")
267 ET.SubElement(cd, "driver", name="qemu", type="raw")
268 ET.SubElement(cd, "source", file=str(source))
269 ET.SubElement(cd, "target", dev="sda", bus="sata")
270 ET.SubElement(cd, "readonly")
271 nic = ET.SubElement(devices, "interface", type="network")
272 ET.SubElement(nic, "source", network="default")
273 ET.SubElement(nic, "model", type="virtio")
274 ET.SubElement(devices, "graphics", type="vnc", port="-1", autoport="yes", listen="127.0.0.1")
275 ET.SubElement(devices, "console", type="pty")
276 ET.SubElement(devices, "channel", type="unix").append(ET.Element("target", type="virtio", name="org.qemu.guest_agent.0"))
277 xml = directory / "domain.xml"
278 xml.write_bytes(ET.tostring(root))
279 signal.alarm(0)
280 virsh("define", str(xml))
281 if spec["autostart"]:
282 virsh("autostart", name)
283 if spec["start"]:
284 ensure_network()
285 virsh("start", name)
286 except Exception:
287 signal.alarm(0)
288 if name not in virsh("list", "--all", "--name").splitlines():
289 for file in directory.iterdir():
290 file.unlink()
291 directory.rmdir()
292 raise
293 finally:
294 signal.alarm(0)
295 signal.signal(signal.SIGALRM, previous)
296
297
298def main():
299 action = sys.argv[1]
300 payload = json.loads(sys.argv[2]) if len(sys.argv) > 2 else None
301 validate(action, payload)
302 if action == "node":
303 return node()
304 if action == "domains":
305 return domains()
306 if action == "stats":
307 result = {}
308 for name in virsh("list", "--name").splitlines():
309 pid_file = Path("/run/libvirt/qemu") / f"{name}.pid"
310 if not pid_file.exists():
311 continue
312 pid = pid_file.read_text().strip()
313 fields = Path(f"/proc/{pid}/stat").read_text().rsplit(") ", 1)[1].split()
314 resident = int(Path(f"/proc/{pid}/statm").read_text().split()[1]) * os.sysconf("SC_PAGE_SIZE")
315 result[name] = {"cpu": (int(fields[11]) + int(fields[12])) / os.sysconf("SC_CLK_TCK"), "memory": resident,
316 "vcpus": int(ET.fromstring(virsh("dumpxml", name)).findtext("vcpu", "1"))}
317 return result
318 if action == "images":
319 images = [image(file) for file in sorted(IMAGES.iterdir()) if not file.is_symlink()] if IMAGES.is_dir() else []
320 return [{"volume": "blank", "os": "Blank disk", "kind": "installer", "capacity": 0,
321 "recommended": {"vcpus": 2, "memory": 2 * 2**30, "disk": 20 * 2**30}}] + [item for item in images if item]
322 if action == "create":
323 create(payload)
324 elif action == "act":
325 name = payload["name"]
326 ensure_name(name)
327 if payload["action"] == "start":
328 ensure_network()
329 virsh({"start": "start", "shutdown": "shutdown", "reboot": "reboot", "destroy": "destroy", "resume": "resume"}[payload["action"]], name)
330 elif action == "update":
331 name = payload["name"]
332 ensure_name(name)
333 if "autostart" in payload:
334 virsh("autostart", *([] if payload["autostart"] else ["--disable"]), name)
335 if "description" in payload:
336 flags = ["--config"]
337 if virsh("domstate", name).strip() != "shut off":
338 flags.append("--live")
339 virsh("desc", name, *flags, "--", payload["description"])
340 elif action == "remove":
341 name = payload["name"]
342 ensure_name(name)
343 if name not in virsh("list", "--all", "--name").splitlines():
344 raise ValueError("VM does not exist")
345 if virsh("domstate", name).strip() != "shut off":
346 virsh("destroy", name)
347 virsh("undefine", name)
348 directory = DISKS / name
349 if payload["disks"] and directory.is_dir():
350 for file in directory.iterdir():
351 file.unlink()
352 directory.rmdir()
353 else:
354 raise ValueError("unsupported VM action")
355 return None
356
357
358if __name__ == "__main__":
359 try:
360 print(json.dumps(main()))
361 except ValueError as failure:
362 print(str(failure), file=sys.stderr)
363 sys.exit(2)
364 except (OSError, subprocess.CalledProcessError) as failure:
365 print(str(failure), file=sys.stderr)
366 sys.exit(1)