kento-core 1.6.0.dev1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
kento/vm.py ADDED
@@ -0,0 +1,504 @@
1
+ """Gemet VM mode — QEMU + virtiofs VM management."""
2
+
3
+ import hashlib
4
+ import logging
5
+ import os
6
+ import re
7
+ import signal
8
+ import socket
9
+ import subprocess
10
+ import time
11
+ from pathlib import Path
12
+
13
+ from kento import VM_BASE
14
+ from kento.defaults import VM_MEMORY, VM_CORES, VM_KVM, VM_MACHINE
15
+ from kento.errors import StateError
16
+ from kento.subprocess_util import run_or_die
17
+
18
+ logger = logging.getLogger("kento")
19
+
20
+ # Locally-administered MAC prefix (QEMU's standard block).
21
+ MAC_PREFIX = "52:54:00"
22
+
23
+ _MAC_RE = re.compile(r"^[0-9a-fA-F]{2}(:[0-9a-fA-F]{2}){5}$")
24
+
25
+
26
+ def is_valid_mac(mac: str) -> bool:
27
+ """Return True if mac is a valid colon-separated 6-pair hex MAC."""
28
+ return bool(_MAC_RE.match(mac))
29
+
30
+
31
+ def generate_mac(identifier: str) -> str:
32
+ """Return a stable deterministic MAC address for the given identifier.
33
+
34
+ The identifier is the container name (plain VM) or VMID (PVE-VM).
35
+ Uses 52:54:00 prefix + 3 bytes from sha256(identifier).
36
+ """
37
+ digest = hashlib.sha256(identifier.encode()).digest()[:3]
38
+ suffix = ":".join(f"{b:02x}" for b in digest)
39
+ return f"{MAC_PREFIX}:{suffix}"
40
+
41
+ # virtiofsd is often installed outside PATH (e.g. /usr/libexec/virtiofsd on Debian)
42
+ _VIRTIOFSD_SEARCH = ["/usr/libexec/virtiofsd", "/usr/lib/qemu/virtiofsd",
43
+ "/usr/lib/virtiofsd", "/usr/bin/virtiofsd"]
44
+
45
+
46
+ def _find_virtiofsd() -> str:
47
+ """Locate the virtiofsd binary."""
48
+ import shutil
49
+ path = shutil.which("virtiofsd")
50
+ if path:
51
+ return path
52
+ for candidate in _VIRTIOFSD_SEARCH:
53
+ if Path(candidate).is_file():
54
+ return candidate
55
+ raise StateError("virtiofsd not found. Install virtiofsd or check PATH.")
56
+
57
+
58
+ def _find_qemu() -> str:
59
+ """Locate the qemu-system-x86_64 binary.
60
+
61
+ Mirrors _find_virtiofsd so we fail cleanly (before spawning virtiofsd or
62
+ mounting anything) when QEMU is absent, rather than leaking a virtiofsd
63
+ process and mount on a Popen FileNotFoundError.
64
+ """
65
+ import shutil
66
+ path = shutil.which("qemu-system-x86_64")
67
+ if path:
68
+ return path
69
+ raise StateError("qemu-system-x86_64 not found. Install QEMU or check PATH.")
70
+
71
+
72
+ _PORT_MIN = 10022
73
+ _PORT_MAX = 10999
74
+
75
+
76
+ def _port_is_free(port: int) -> bool:
77
+ """Check if a TCP port is available on localhost."""
78
+ try:
79
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
80
+ s.bind(("127.0.0.1", port))
81
+ return True
82
+ except OSError:
83
+ return False
84
+
85
+
86
+ def allocate_port() -> int:
87
+ """Return the next free host port in range 10022-10999.
88
+
89
+ Scans kento-port files in both LXC and VM base directories to find used
90
+ ports, then verifies the candidate port is actually free on the host.
91
+ """
92
+ from kento import LXC_BASE
93
+ used_ports: set[int] = set()
94
+ for base in (VM_BASE, LXC_BASE):
95
+ if base.is_dir():
96
+ for d in base.iterdir():
97
+ if d.is_dir():
98
+ port_file = d / "kento-port"
99
+ if port_file.is_file():
100
+ try:
101
+ host_port = int(port_file.read_text().strip().split(":")[0])
102
+ used_ports.add(host_port)
103
+ except (ValueError, IndexError):
104
+ continue
105
+ for port in range(_PORT_MIN, _PORT_MAX + 1):
106
+ if port not in used_ports and _port_is_free(port):
107
+ return port
108
+ raise StateError("no free port in range 10022-10999")
109
+
110
+
111
+ def is_vm_running(container_dir: Path) -> bool:
112
+ """Check if a VM is running by verifying the QEMU PID file."""
113
+ pid_file = container_dir / "kento-qemu-pid"
114
+ if not pid_file.is_file():
115
+ return False
116
+ try:
117
+ pid = int(pid_file.read_text().strip())
118
+ return Path(f"/proc/{pid}").is_dir()
119
+ except (ValueError, OSError):
120
+ return False
121
+
122
+
123
+ def _is_mountpoint(path: Path) -> bool:
124
+ """Check if a path is a mountpoint."""
125
+ return subprocess.run(
126
+ ["mountpoint", "-q", str(path)], capture_output=True,
127
+ ).returncode == 0
128
+
129
+
130
+ def _umount_with_retry(path: Path, force: bool) -> bool:
131
+ """Umount ``path`` with a busy-mount escape hatch.
132
+
133
+ Two-stage strategy:
134
+
135
+ 1. Plain ``umount <path>``. If it succeeds, return True.
136
+ 2. On failure, try to free up the mount: invoke ``fuser -km <path>``
137
+ to kill any holders (best-effort — fuser may not be installed and
138
+ any failure here is swallowed), then retry with ``umount -l``
139
+ (lazy unmount, which detaches the mount on the last reference).
140
+
141
+ If the lazy umount also fails:
142
+
143
+ * ``force=True``: log a warning and return True so the caller (e.g.
144
+ ``destroy -f``) can still proceed with rmtree. The lazy umount,
145
+ even on failure path, is best-effort cleanup; the kernel detaches
146
+ whenever the last reference drops.
147
+ * ``force=False``: return False and let the caller raise.
148
+ """
149
+ # First attempt — plain umount.
150
+ result = subprocess.run(["umount", str(path)], capture_output=True, text=True)
151
+ if result.returncode == 0:
152
+ return True
153
+
154
+ stderr = (result.stderr or "").strip()
155
+ logger.warning("umount %s failed (exit %d): %s", path, result.returncode, stderr)
156
+
157
+ # Best-effort: kill processes holding the mount. fuser is in psmisc,
158
+ # which is not always installed; tolerate either FileNotFoundError or
159
+ # a non-zero return.
160
+ try:
161
+ subprocess.run(
162
+ ["fuser", "-km", str(path)],
163
+ capture_output=True,
164
+ )
165
+ except (FileNotFoundError, OSError):
166
+ pass
167
+
168
+ # Retry with lazy umount.
169
+ lazy = subprocess.run(["umount", "-l", str(path)], capture_output=True, text=True)
170
+ if lazy.returncode == 0:
171
+ return True
172
+
173
+ lazy_stderr = (lazy.stderr or "").strip()
174
+ if force:
175
+ logger.warning(
176
+ "lazy umount %s also failed (exit %d): %s; proceeding anyway "
177
+ "(rmtree will detach on last ref).",
178
+ path, lazy.returncode, lazy_stderr,
179
+ )
180
+ return True
181
+ return False
182
+
183
+
184
+ def mount_rootfs(container_dir: Path, layers: str, state_dir: Path) -> None:
185
+ """Mount overlayfs at container_dir/rootfs on the host."""
186
+ rootfs = container_dir / "rootfs"
187
+ if _is_mountpoint(rootfs):
188
+ raise StateError(f"rootfs already mounted at {rootfs}")
189
+ upper = state_dir / "upper"
190
+ work = state_dir / "work"
191
+ opts = f"lowerdir={layers},upperdir={upper},workdir={work}"
192
+ env = {**os.environ, "LIBMOUNT_FORCE_MOUNT2": "always"}
193
+ run_or_die(
194
+ ["mount", "-t", "overlay", "overlay", "-o", opts, str(rootfs)],
195
+ what="mount overlayfs",
196
+ hint=f"common causes: upperdir on overlayfs (set KENTO_STATE_DIR), missing overlay module, stale mount at {rootfs}.",
197
+ env=env,
198
+ )
199
+
200
+
201
+ def unmount_rootfs(container_dir: Path) -> None:
202
+ """Unmount overlayfs at container_dir/rootfs."""
203
+ rootfs = container_dir / "rootfs"
204
+ run_or_die(
205
+ ["umount", str(rootfs)],
206
+ what="unmount rootfs",
207
+ hint="check for open file handles or active processes with 'lsof +D ...'.",
208
+ )
209
+
210
+
211
+ def start_vm(container_dir: Path, name: str) -> None:
212
+ """Start a VM: mount rootfs, launch virtiofsd + QEMU, write PID files."""
213
+ # F15: idempotent — callers in start.py already guard, but this
214
+ # protects direct callers (create --start flow) too. Match CLI
215
+ # wording so users see the same message regardless of entry point.
216
+ if is_vm_running(container_dir):
217
+ logger.info("Already running: %s", name)
218
+ return
219
+
220
+ layers = (container_dir / "kento-layers").read_text().strip()
221
+ state_dir = Path((container_dir / "kento-state").read_text().strip())
222
+
223
+ # Mount overlayfs
224
+ mount_rootfs(container_dir, layers, state_dir)
225
+
226
+ rootfs = container_dir / "rootfs"
227
+
228
+ # Validate kernel and initramfs exist
229
+ kernel = rootfs / "boot" / "vmlinuz"
230
+ initramfs = rootfs / "boot" / "initramfs.img"
231
+ if not kernel.is_file():
232
+ unmount_rootfs(container_dir)
233
+ raise StateError(f"kernel not found at {kernel}")
234
+ if not initramfs.is_file():
235
+ unmount_rootfs(container_dir)
236
+ raise StateError(f"initramfs not found at {initramfs}")
237
+
238
+ # Inject guest-side config (hostname/network/tz/env/ssh-key) into the
239
+ # mounted rootfs before virtiofsd starts. inject.sh reads kento metadata
240
+ # files and writes them into the overlayfs upper layer. A failing inject
241
+ # is a start failure — don't boot a misconfigured VM.
242
+ inject_script = container_dir / "kento-inject.sh"
243
+ if not inject_script.is_file():
244
+ unmount_rootfs(container_dir)
245
+ raise StateError(f"inject script not found at {inject_script}")
246
+ run_or_die(
247
+ ["sh", str(inject_script), str(rootfs), str(container_dir)],
248
+ what="inject guest config",
249
+ name=name,
250
+ hint=f"run 'sh {inject_script} {rootfs} {container_dir}' manually to debug.",
251
+ )
252
+
253
+ # Read port mapping (usermode networking)
254
+ port_file = container_dir / "kento-port"
255
+ host_port = guest_port = None
256
+ if port_file.is_file():
257
+ port_text = port_file.read_text().strip()
258
+ host_port, guest_port = port_text.split(":")
259
+
260
+ # Resolve binaries up-front so an absent QEMU fails cleanly (before we
261
+ # spawn virtiofsd or leak the mount). _find_virtiofsd / _find_qemu both
262
+ # raise StateError on miss; at this point only the mount is held, and the
263
+ # caller-agnostic rollback below has not yet been armed (nothing to undo
264
+ # beyond the mount, which the missing-kernel/initramfs guards above also
265
+ # leave to their own unmount — keep that behaviour for the binary checks).
266
+ virtiofsd_bin = _find_virtiofsd()
267
+ _find_qemu()
268
+ socket_path = container_dir / "virtiofsd.sock"
269
+
270
+ # From here on virtiofsd is running and the rootfs is mounted, so any
271
+ # failure must roll both back. start_vm is called directly by start.py
272
+ # (`kento start` / `kento vm start`) with NO surrounding undo, so it has
273
+ # to be self-cleaning regardless of caller. (create(--start) registers its
274
+ # own vm-stop undo; stop_vm is idempotent, so a double cleanup is safe.)
275
+ virtiofsd = None
276
+ try:
277
+ # Start virtiofsd
278
+ virtiofsd = subprocess.Popen(
279
+ [virtiofsd_bin,
280
+ f"--socket-path={socket_path}",
281
+ f"--shared-dir={rootfs}",
282
+ "--cache=auto"],
283
+ stdin=subprocess.DEVNULL,
284
+ stdout=subprocess.DEVNULL,
285
+ stderr=subprocess.DEVNULL,
286
+ start_new_session=True,
287
+ )
288
+ (container_dir / "kento-virtiofsd-pid").write_text(str(virtiofsd.pid) + "\n")
289
+
290
+ # Wait for socket to appear
291
+ for _ in range(50):
292
+ if socket_path.exists():
293
+ break
294
+ time.sleep(0.1)
295
+
296
+ # Abort if the socket never appeared or virtiofsd died: launching QEMU
297
+ # against a dead vhost-user chardev produces a broken VM and leaks the
298
+ # mount + the virtiofsd process. Mirror the abort-and-unmount logic in
299
+ # vm_hook.py's pre-start phase. The single except below does the cleanup.
300
+ if not socket_path.exists() or virtiofsd.poll() is not None:
301
+ raise StateError(f"virtiofsd socket did not appear at {socket_path}")
302
+
303
+ qemu = _launch_qemu(container_dir, name, rootfs, socket_path)
304
+ except BaseException:
305
+ # Any failure in this block (the socket-abort StateError, qemu Popen
306
+ # FileNotFoundError, OSError reading kento-memory/kento-cores, ...) must
307
+ # roll back virtiofsd + mount + pid files exactly once, then re-raise so
308
+ # the caller sees the original error. Cleanup is idempotent.
309
+ _cleanup_failed_start(container_dir, virtiofsd)
310
+ raise
311
+
312
+ (container_dir / "kento-qemu-pid").write_text(str(qemu.pid) + "\n")
313
+
314
+ logger.info("Started: %s", name)
315
+ port_file = container_dir / "kento-port"
316
+ if port_file.is_file():
317
+ host_port = port_file.read_text().strip().split(":")[0]
318
+ logger.info(" SSH: ssh -p %s root@localhost", host_port)
319
+
320
+
321
+ def _cleanup_failed_start(container_dir: Path, virtiofsd) -> None:
322
+ """Roll back a partially-started VM: kill virtiofsd, unmount, drop pid files.
323
+
324
+ Idempotent and tolerant of a half-built state (virtiofsd may be None, the
325
+ mount may be absent). Used by start_vm's failure paths so it is self-cleaning
326
+ for callers without their own rollback (start.py).
327
+ """
328
+ if virtiofsd is not None:
329
+ try:
330
+ virtiofsd.terminate()
331
+ virtiofsd.wait(timeout=5)
332
+ except subprocess.TimeoutExpired:
333
+ try:
334
+ virtiofsd.kill()
335
+ virtiofsd.wait(timeout=5)
336
+ except (subprocess.TimeoutExpired, OSError):
337
+ pass
338
+ except (OSError, ValueError):
339
+ pass
340
+ (container_dir / "kento-virtiofsd-pid").unlink(missing_ok=True)
341
+ (container_dir / "kento-qemu-pid").unlink(missing_ok=True)
342
+ rootfs = container_dir / "rootfs"
343
+ if _is_mountpoint(rootfs):
344
+ _umount_with_retry(rootfs, force=True)
345
+ (container_dir / "virtiofsd.sock").unlink(missing_ok=True)
346
+
347
+
348
+ def _launch_qemu(container_dir: Path, name: str, rootfs: Path,
349
+ socket_path: Path):
350
+ """Build the QEMU argv and launch it detached. Returns the Popen object."""
351
+ kernel = rootfs / "boot" / "vmlinuz"
352
+ initramfs = rootfs / "boot" / "initramfs.img"
353
+
354
+ # Read port mapping (usermode networking)
355
+ port_file = container_dir / "kento-port"
356
+ host_port = guest_port = None
357
+ if port_file.is_file():
358
+ port_text = port_file.read_text().strip()
359
+ host_port, guest_port = port_text.split(":")
360
+
361
+ memory_file = container_dir / "kento-memory"
362
+ if memory_file.is_file():
363
+ memory = memory_file.read_text().strip()
364
+ else:
365
+ memory = str(VM_MEMORY)
366
+ cores_file = container_dir / "kento-cores"
367
+ if cores_file.is_file():
368
+ cores = cores_file.read_text().strip()
369
+ else:
370
+ cores = str(VM_CORES)
371
+ # Nesting (v1.3.0): kento-nesting holds "1" (expose vmx/svm so the guest
372
+ # can run accelerated nested VMs) or "0"/absent (mask them). Same read
373
+ # pattern as kento-memory/kento-cores above.
374
+ nesting_file = container_dir / "kento-nesting"
375
+ nesting_on = nesting_file.is_file() and nesting_file.read_text().strip() == "1"
376
+ # Serial + QMP unix sockets (v1.4.0 VM-interactive). Mirror the
377
+ # virtiofsd.sock naming: Path under container_dir, str()'d into the argv.
378
+ # serial.sock carries the guest console (console=ttyS0 in -append below);
379
+ # a later `attach` command relays a tty to it. qmp.sock exposes QEMU's
380
+ # monitor protocol (item-1 suspend/resume prep). Both use server=on so
381
+ # QEMU is the listener, wait=off so it does NOT block at boot waiting for
382
+ # a client (QEMU is started detached).
383
+ serial_socket_path = container_dir / "serial.sock"
384
+ qmp_socket_path = container_dir / "qmp.sock"
385
+ qemu_cmd = ["qemu-system-x86_64",
386
+ "-kernel", str(kernel),
387
+ "-initrd", str(initramfs),
388
+ "-m", memory,
389
+ "-smp", cores,
390
+ "-machine", VM_MACHINE,
391
+ ]
392
+ if VM_KVM:
393
+ # CPU model is always `host`. Nesting OFF deterministically strips the
394
+ # hardware virt extensions (vmx/svm) even on a nesting-enabled host, so
395
+ # the guest cannot start its own accelerated VMs unless --allow-nesting
396
+ # was set. No KVM → no -cpu (TCG default); nesting needs KVM anyway.
397
+ # Emitted before the kento-qemu-args pass-through below so a user
398
+ # --qemu-arg '-cpu ...' can still override (QEMU honours the last -cpu).
399
+ cpu = "host" if nesting_on else "host,vmx=off,svm=off"
400
+ qemu_cmd += ["-enable-kvm", "-cpu", cpu]
401
+ qemu_cmd += [
402
+ # -display none (not -nographic): suppress any graphical display
403
+ # without aliasing the guest serial to QEMU's stdio (QEMU is
404
+ # detached). Serial is attached explicitly to serial.sock below.
405
+ "-display", "none",
406
+ "-serial", f"unix:{serial_socket_path},server=on,wait=off",
407
+ "-qmp", f"unix:{qmp_socket_path},server=on,wait=off",
408
+ "-chardev", f"socket,id=vfs,path={socket_path}",
409
+ "-device", "vhost-user-fs-pci,chardev=vfs,tag=rootfs",
410
+ "-object", f"memory-backend-memfd,id=mem,size={memory}M,share=on",
411
+ "-numa", "node,memdev=mem",
412
+ ]
413
+ if host_port is not None:
414
+ # Include MAC if available (kento-mac written at create time for VM modes)
415
+ mac_file = container_dir / "kento-mac"
416
+ device = "virtio-net-pci,netdev=net0"
417
+ if mac_file.is_file():
418
+ mac = mac_file.read_text().strip()
419
+ if mac:
420
+ device = f"virtio-net-pci,netdev=net0,mac={mac}"
421
+ qemu_cmd += [
422
+ "-netdev", f"user,id=net0,hostfwd=tcp:127.0.0.1:{host_port}-:{guest_port}",
423
+ "-device", device,
424
+ ]
425
+ qemu_cmd += [
426
+ "-append", "console=ttyS0 rootfstype=virtiofs root=rootfs",
427
+ ]
428
+
429
+ # Pass-through flags (v1.2.0 Phase B). kento-qemu-args is written at
430
+ # create time by --qemu-arg; each non-empty line becomes one argv
431
+ # element. Appended AFTER kento's own argv so QEMU's last-occurrence
432
+ # semantics lets users override kento defaults (e.g. --qemu-arg '-m 2048'
433
+ # overrides the -m <memory> emitted above). One line = one argv element —
434
+ # no shell splitting. For a flag with a separate value, users pass two
435
+ # --qemu-arg flags (or use the -flag=value form). Tolerate missing file.
436
+ # Skip blank AND whitespace-only lines: a lone whitespace token would
437
+ # otherwise become an empty/positional argv element that QEMU rejects.
438
+ # Matches the stricter PVE path (pve.py rejects whitespace-only args).
439
+ passthrough_file = container_dir / "kento-qemu-args"
440
+ if passthrough_file.is_file():
441
+ for raw in passthrough_file.read_text().splitlines():
442
+ line = raw.strip()
443
+ if line:
444
+ qemu_cmd.append(line)
445
+
446
+ # Return the Popen; start_vm owns the kento-qemu-pid write and the
447
+ # "Started:"/SSH output so the pid file is written exactly once and the
448
+ # cleanup-on-failure path in start_vm stays the sole owner of that state.
449
+ return subprocess.Popen(
450
+ qemu_cmd,
451
+ stdin=subprocess.DEVNULL,
452
+ stdout=subprocess.DEVNULL,
453
+ stderr=subprocess.DEVNULL,
454
+ start_new_session=True,
455
+ )
456
+
457
+
458
+ def _kill_and_wait(pid_file: Path, timeout: float = 5.0, *, force: bool = False) -> None:
459
+ """Send SIGTERM (or SIGKILL if force) to a process and wait for it to exit."""
460
+ if not pid_file.is_file():
461
+ return
462
+ try:
463
+ pid = int(pid_file.read_text().strip())
464
+ except (ValueError, OSError):
465
+ pid_file.unlink(missing_ok=True)
466
+ return
467
+ try:
468
+ os.kill(pid, signal.SIGKILL if force else signal.SIGTERM)
469
+ except ProcessLookupError:
470
+ pid_file.unlink(missing_ok=True)
471
+ return
472
+ # Wait for process to exit
473
+ deadline = time.monotonic() + timeout
474
+ while time.monotonic() < deadline:
475
+ if not Path(f"/proc/{pid}").is_dir():
476
+ break
477
+ time.sleep(0.1)
478
+ else:
479
+ # Still alive — force kill
480
+ try:
481
+ os.kill(pid, signal.SIGKILL)
482
+ except ProcessLookupError:
483
+ pass
484
+ pid_file.unlink(missing_ok=True)
485
+
486
+
487
+ def stop_vm(container_dir: Path, *, force: bool = False) -> None:
488
+ """Stop a VM: kill QEMU + virtiofsd, unmount rootfs, clean up."""
489
+ _kill_and_wait(container_dir / "kento-qemu-pid", force=force)
490
+ _kill_and_wait(container_dir / "kento-virtiofsd-pid", force=force)
491
+
492
+ # Unmount rootfs. Use the busy-mount-hardened helper so a wedged QEMU
493
+ # or virtiofsd that left a stale handle doesn't permanently block stop.
494
+ # stop_vm only flips to lazy/force semantics when called with force=True
495
+ # (the destroy -f path); a plain stop preserves the strict failure mode.
496
+ rootfs = container_dir / "rootfs"
497
+ if _is_mountpoint(rootfs):
498
+ if not _umount_with_retry(rootfs, force=force):
499
+ raise StateError(f"failed to unmount {rootfs}. Is the instance still running?")
500
+
501
+ # Clean up sockets (virtiofsd + serial/qmp from VM-interactive wiring).
502
+ (container_dir / "virtiofsd.sock").unlink(missing_ok=True)
503
+ (container_dir / "serial.sock").unlink(missing_ok=True)
504
+ (container_dir / "qmp.sock").unlink(missing_ok=True)