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/__init__.py +461 -0
- kento/attach.py +188 -0
- kento/cloudinit.py +201 -0
- kento/create.py +1205 -0
- kento/defaults.py +207 -0
- kento/destroy.py +131 -0
- kento/diagnose.py +548 -0
- kento/errors.py +48 -0
- kento/exec_cmd.py +50 -0
- kento/hook.py +28 -0
- kento/hook.sh +525 -0
- kento/images.py +210 -0
- kento/info.py +274 -0
- kento/inject.py +28 -0
- kento/inject.sh +282 -0
- kento/layers.py +81 -0
- kento/list.py +146 -0
- kento/locking.py +64 -0
- kento/logs.py +48 -0
- kento/lxc_hook.py +61 -0
- kento/pve.py +619 -0
- kento/reset.py +212 -0
- kento/set_cmd.py +1036 -0
- kento/start.py +65 -0
- kento/stop.py +210 -0
- kento/subprocess_util.py +76 -0
- kento/suspend.py +196 -0
- kento/vm.py +504 -0
- kento/vm_hook.py +256 -0
- kento_core-1.6.0.dev1.dist-info/METADATA +8 -0
- kento_core-1.6.0.dev1.dist-info/RECORD +34 -0
- kento_core-1.6.0.dev1.dist-info/WHEEL +5 -0
- kento_core-1.6.0.dev1.dist-info/licenses/LICENSE.md +594 -0
- kento_core-1.6.0.dev1.dist-info/top_level.txt +1 -0
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)
|