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/pve.py
ADDED
|
@@ -0,0 +1,619 @@
|
|
|
1
|
+
"""Proxmox VE integration — VMID allocation and PVE config generation."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import platform
|
|
7
|
+
import socket
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from kento import LXC_BASE, VM_BASE
|
|
11
|
+
from kento.defaults import LXC_TTY, LXC_MOUNT_AUTO, LXC_MOUNT_AUTO_NESTING
|
|
12
|
+
from kento.errors import ValidationError
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger("kento")
|
|
15
|
+
|
|
16
|
+
PVE_DIR = Path("/etc/pve")
|
|
17
|
+
PVE_LXC_DIR = PVE_DIR / "lxc"
|
|
18
|
+
PVE_QEMU_DIR = PVE_DIR / "qemu-server"
|
|
19
|
+
|
|
20
|
+
_ARCH_MAP = {
|
|
21
|
+
"x86_64": "amd64",
|
|
22
|
+
"aarch64": "arm64",
|
|
23
|
+
"i686": "i386",
|
|
24
|
+
"i386": "i386",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _pve_arch() -> str:
|
|
29
|
+
m = platform.machine()
|
|
30
|
+
return _ARCH_MAP.get(m, m)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _pve_node_name() -> str:
|
|
34
|
+
"""Get the PVE node name from /etc/pve/local symlink.
|
|
35
|
+
|
|
36
|
+
PVE's /etc/pve/local is a symlink to /etc/pve/nodes/<node-name>.
|
|
37
|
+
The node name defaults to the hostname but can differ (set during
|
|
38
|
+
PVE installation). Fall back to socket.gethostname() if the
|
|
39
|
+
symlink doesn't exist (shouldn't happen on a real PVE host).
|
|
40
|
+
"""
|
|
41
|
+
local = PVE_DIR / "local"
|
|
42
|
+
if local.is_symlink():
|
|
43
|
+
return Path(os.readlink(local)).name
|
|
44
|
+
return socket.gethostname()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def is_pve() -> bool:
|
|
48
|
+
"""Return True if running on a Proxmox VE host."""
|
|
49
|
+
return PVE_DIR.is_dir()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _pve_view_vmids() -> set[int]:
|
|
53
|
+
"""Return the set of VMIDs PVE itself knows about.
|
|
54
|
+
|
|
55
|
+
Fast path: reads /etc/pve/.vmlist (JSON index).
|
|
56
|
+
Fallback: scans /etc/pve/lxc/*.conf and /etc/pve/qemu-server/*.conf.
|
|
57
|
+
"""
|
|
58
|
+
vmlist = PVE_DIR / ".vmlist"
|
|
59
|
+
if vmlist.is_file():
|
|
60
|
+
try:
|
|
61
|
+
data = json.loads(vmlist.read_text())
|
|
62
|
+
return {int(k) for k in data.get("ids", {})}
|
|
63
|
+
except (json.JSONDecodeError, ValueError):
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
# Fallback: scan config directories
|
|
67
|
+
ids: set[int] = set()
|
|
68
|
+
for d in (PVE_LXC_DIR, PVE_QEMU_DIR):
|
|
69
|
+
if d.is_dir():
|
|
70
|
+
for f in d.glob("*.conf"):
|
|
71
|
+
try:
|
|
72
|
+
ids.add(int(f.stem))
|
|
73
|
+
except ValueError:
|
|
74
|
+
continue
|
|
75
|
+
return ids
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _kento_recorded_vmids() -> set[int]:
|
|
79
|
+
"""Return VMIDs kento itself has recorded for PVE instances.
|
|
80
|
+
|
|
81
|
+
PVE's view (`_pve_view_vmids`) misses an instance whose `.conf` was
|
|
82
|
+
destroyed out-of-band (an "orphan"): kento still owns the instance dir
|
|
83
|
+
but PVE reports the VMID as free, so a naive allocation would re-hand it
|
|
84
|
+
out and collide. Union these in so allocation/validation reserve them.
|
|
85
|
+
|
|
86
|
+
- PVE-LXC: the instance dir lives under LXC_BASE and its NAME is the
|
|
87
|
+
VMID (an integer) — mirrors list.py's `vmid = container_dir.name`.
|
|
88
|
+
- PVE-VM: the instance dir lives under VM_BASE; the VMID is stored in a
|
|
89
|
+
``kento-vmid`` file inside it.
|
|
90
|
+
|
|
91
|
+
Best-effort and purely additive: missing base dirs, non-integer dir
|
|
92
|
+
names, and missing/malformed ``kento-vmid`` files are skipped, never
|
|
93
|
+
fatal. Nothing here reaps or mutates state.
|
|
94
|
+
"""
|
|
95
|
+
ids: set[int] = set()
|
|
96
|
+
|
|
97
|
+
# PVE-LXC: dir name is the VMID.
|
|
98
|
+
if LXC_BASE.is_dir():
|
|
99
|
+
for d in LXC_BASE.iterdir():
|
|
100
|
+
if not d.is_dir():
|
|
101
|
+
continue
|
|
102
|
+
try:
|
|
103
|
+
ids.add(int(d.name))
|
|
104
|
+
except ValueError:
|
|
105
|
+
continue
|
|
106
|
+
|
|
107
|
+
# PVE-VM: VMID recorded in the kento-vmid file.
|
|
108
|
+
if VM_BASE.is_dir():
|
|
109
|
+
for d in VM_BASE.iterdir():
|
|
110
|
+
vmid_file = d / "kento-vmid"
|
|
111
|
+
if not vmid_file.is_file():
|
|
112
|
+
continue
|
|
113
|
+
try:
|
|
114
|
+
ids.add(int(vmid_file.read_text().strip()))
|
|
115
|
+
except (ValueError, OSError):
|
|
116
|
+
continue
|
|
117
|
+
|
|
118
|
+
return ids
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _used_vmids() -> set[int]:
|
|
122
|
+
"""Return the set of VMIDs already in use.
|
|
123
|
+
|
|
124
|
+
Union of (a) PVE's own view (`_pve_view_vmids`) and (b) kento's recorded
|
|
125
|
+
VMIDs (`_kento_recorded_vmids`) so that orphaned kento instances — whose
|
|
126
|
+
PVE `.conf` was destroyed out-of-band — still reserve their VMID and are
|
|
127
|
+
not reassigned.
|
|
128
|
+
"""
|
|
129
|
+
return _pve_view_vmids() | _kento_recorded_vmids()
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def next_vmid() -> int:
|
|
133
|
+
"""Return the lowest free VMID >= 100."""
|
|
134
|
+
used = _used_vmids()
|
|
135
|
+
vmid = 100
|
|
136
|
+
while vmid in used:
|
|
137
|
+
vmid += 1
|
|
138
|
+
return vmid
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def validate_vmid(vmid: int) -> None:
|
|
142
|
+
"""Raise ValidationError if VMID is invalid or already taken."""
|
|
143
|
+
if vmid < 100:
|
|
144
|
+
raise ValidationError(
|
|
145
|
+
f"VMID must be >= 100, got {vmid} "
|
|
146
|
+
f"(VMIDs 1-99 are reserved by Proxmox for internal use)."
|
|
147
|
+
)
|
|
148
|
+
used = _used_vmids()
|
|
149
|
+
if vmid in used:
|
|
150
|
+
raise ValidationError(f"VMID {vmid} is already in use")
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def write_pve_config(vmid: int, content: str) -> Path:
|
|
154
|
+
"""Write a PVE config to /etc/pve/nodes/<node>/lxc/<VMID>.conf.
|
|
155
|
+
|
|
156
|
+
pmxcfs (the PVE cluster filesystem) requires directories to be created
|
|
157
|
+
one level at a time — os.makedirs() doesn't work because stat() returns
|
|
158
|
+
ENOENT on empty virtual directories while mkdir() returns EEXIST.
|
|
159
|
+
"""
|
|
160
|
+
node = _pve_node_name()
|
|
161
|
+
conf_dir = PVE_DIR / "nodes" / node / "lxc"
|
|
162
|
+
# Create each directory level, ignoring EEXIST (pmxcfs quirk)
|
|
163
|
+
for parent in [PVE_DIR / "nodes", PVE_DIR / "nodes" / node, conf_dir]:
|
|
164
|
+
try:
|
|
165
|
+
parent.mkdir()
|
|
166
|
+
except FileExistsError:
|
|
167
|
+
pass
|
|
168
|
+
conf_path = conf_dir / f"{vmid}.conf"
|
|
169
|
+
conf_path.write_text(content)
|
|
170
|
+
return conf_path
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def delete_pve_config(vmid: int) -> None:
|
|
174
|
+
"""Delete a PVE config from /etc/pve/nodes/<node>/lxc/<VMID>.conf.
|
|
175
|
+
|
|
176
|
+
No error if the file doesn't exist (idempotent).
|
|
177
|
+
"""
|
|
178
|
+
node = _pve_node_name()
|
|
179
|
+
conf_path = PVE_DIR / "nodes" / node / "lxc" / f"{vmid}.conf"
|
|
180
|
+
conf_path.unlink(missing_ok=True)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def generate_pve_config(name: str, vmid: int, container_dir: Path, *,
|
|
184
|
+
bridge: str | None = None, net_type: str | None = None,
|
|
185
|
+
nesting: bool = False,
|
|
186
|
+
ip: str | None = None,
|
|
187
|
+
gateway: str | None = None,
|
|
188
|
+
nameserver: str | None = None,
|
|
189
|
+
searchdomain: str | None = None,
|
|
190
|
+
timezone: str | None = None,
|
|
191
|
+
env: list[str] | None = None,
|
|
192
|
+
port: str | None = None,
|
|
193
|
+
memory: int | None = None,
|
|
194
|
+
cores: int | None = None,
|
|
195
|
+
hookscript_ref: str | None = None,
|
|
196
|
+
unprivileged: bool = False) -> str:
|
|
197
|
+
"""Generate a PVE-format LXC config for /etc/pve/lxc/<VMID>.conf."""
|
|
198
|
+
hook = container_dir / "kento-hook"
|
|
199
|
+
lines = [
|
|
200
|
+
f"arch: {_pve_arch()}",
|
|
201
|
+
"ostype: unmanaged",
|
|
202
|
+
f"hostname: {name}",
|
|
203
|
+
f"rootfs: {container_dir}/rootfs",
|
|
204
|
+
]
|
|
205
|
+
if unprivileged:
|
|
206
|
+
lines.append("unprivileged: 1")
|
|
207
|
+
# Network config based on net_type
|
|
208
|
+
if net_type == "bridge" and bridge:
|
|
209
|
+
lines.append(
|
|
210
|
+
"net0: name=eth0,bridge={bridge}{ip_part}{gw_part},type=veth".format(
|
|
211
|
+
bridge=bridge,
|
|
212
|
+
ip_part=f",ip={ip}" if ip else "",
|
|
213
|
+
gw_part=f",gw={gateway}" if gateway else "",
|
|
214
|
+
)
|
|
215
|
+
)
|
|
216
|
+
elif net_type == "host":
|
|
217
|
+
lines.append("lxc.net.0.type: none") # shares host network
|
|
218
|
+
elif bridge: # backward compat: bridge passed without net_type
|
|
219
|
+
lines.append(
|
|
220
|
+
"net0: name=eth0,bridge={bridge}{ip_part}{gw_part},type=veth".format(
|
|
221
|
+
bridge=bridge,
|
|
222
|
+
ip_part=f",ip={ip}" if ip else "",
|
|
223
|
+
gw_part=f",gw={gateway}" if gateway else "",
|
|
224
|
+
)
|
|
225
|
+
)
|
|
226
|
+
# net_type == "none" or net_type is None with no bridge: no network lines
|
|
227
|
+
if nameserver:
|
|
228
|
+
lines.append(f"nameserver: {nameserver}")
|
|
229
|
+
if searchdomain:
|
|
230
|
+
lines.append(f"searchdomain: {searchdomain}")
|
|
231
|
+
if timezone:
|
|
232
|
+
lines.append(f"timezone: {timezone}")
|
|
233
|
+
if nesting:
|
|
234
|
+
lines.append("features: nesting=1")
|
|
235
|
+
lines.append("lxc.mount.entry: proc dev/.lxc/proc proc create=dir,optional 0 0")
|
|
236
|
+
lines.append("lxc.mount.entry: sys dev/.lxc/sys sysfs create=dir,optional 0 0")
|
|
237
|
+
lines.append("lxc.mount.entry: /dev/fuse dev/fuse none bind,create=file,optional 0 0")
|
|
238
|
+
lines.append("lxc.mount.entry: /dev/net/tun dev/net/tun none bind,create=file,optional 0 0")
|
|
239
|
+
# Hook point for the overlay assembly (per-layer idmap for unprivileged).
|
|
240
|
+
# Privileged: pre-mount. The container has no userns, so pre-mount runs in
|
|
241
|
+
# the host initial namespace as real root — exactly where the overlay mount
|
|
242
|
+
# belongs. (In the green privileged regression.)
|
|
243
|
+
# Unprivileged: pre-start, which is the ONLY hook that runs in the host
|
|
244
|
+
# INITIAL namespace as real root. pre-mount and mount both run in the
|
|
245
|
+
# container's CHILD userns (mapped uid 100000), where creating an idmapped
|
|
246
|
+
# bind mount fails with EPERM — so the per-layer idmap assembly cannot
|
|
247
|
+
# happen there. Verified empirically on bifrost (run 19: pre-start has
|
|
248
|
+
# uid_map `0 0 4294967295` + full caps and assembles cleanly; pre-mount/
|
|
249
|
+
# mount run with uid_map `0 100000 65536` and hit EPERM) and corroborated by
|
|
250
|
+
# deep-research (pre-start is the sole host-ns hook). The earlier spike's
|
|
251
|
+
# pre-mount->mount switch rested on the false belief that mount runs as real
|
|
252
|
+
# root; it does not.
|
|
253
|
+
if unprivileged:
|
|
254
|
+
lines.append(f"lxc.hook.pre-start: {hook}")
|
|
255
|
+
else:
|
|
256
|
+
lines.append(f"lxc.hook.pre-mount: {hook}")
|
|
257
|
+
# start-host runs on the host after the container is running. We use it for
|
|
258
|
+
# (a) nftables DNAT port-forwarding, and (b) propagating memory/cores into
|
|
259
|
+
# the inner `ns` cgroup on PVE-LXC so the guest sees its own limit instead
|
|
260
|
+
# of "max" (the outer cgroup gets the ceiling but `lxc.cgroup.dir.container.inner`
|
|
261
|
+
# nests the actual namespace one level deeper). Register it whenever any of
|
|
262
|
+
# those features need it.
|
|
263
|
+
_post_stop_emitted = False
|
|
264
|
+
if hookscript_ref is not None:
|
|
265
|
+
lines.append(f"hookscript: {hookscript_ref}")
|
|
266
|
+
elif port is not None or memory is not None or cores is not None:
|
|
267
|
+
lines.append(f"lxc.hook.start-host: {hook}")
|
|
268
|
+
lines.append(f"lxc.hook.post-stop: {hook}")
|
|
269
|
+
_post_stop_emitted = True
|
|
270
|
+
# Unprivileged containers require post-stop to clean up idmapped bind
|
|
271
|
+
# mounts ($STATE_DIR/idmap). Register it unconditionally when unprivileged
|
|
272
|
+
# is True, but only if the port/memory/cores branch hasn't already done so
|
|
273
|
+
# (avoid a duplicate lxc.hook.post-stop line).
|
|
274
|
+
if unprivileged and not _post_stop_emitted:
|
|
275
|
+
lines.append(f"lxc.hook.post-stop: {hook}")
|
|
276
|
+
mount_auto = LXC_MOUNT_AUTO_NESTING if nesting else LXC_MOUNT_AUTO
|
|
277
|
+
lines.append(f"lxc.mount.auto: {mount_auto}")
|
|
278
|
+
lines.append(f"lxc.tty.max: {LXC_TTY}")
|
|
279
|
+
if env:
|
|
280
|
+
for e in env:
|
|
281
|
+
lines.append(f"lxc.environment: {e}")
|
|
282
|
+
if memory is not None:
|
|
283
|
+
lines.append(f"memory: {memory}")
|
|
284
|
+
# Also write the raw cgroup field so the guest's cgroup namespace
|
|
285
|
+
# sees the limit. PVE's `memory:` shorthand propagates to the host
|
|
286
|
+
# cgroup, but older PVE versions don't always mirror the value
|
|
287
|
+
# into the guest's cgroup root — so `cat /sys/fs/cgroup/memory.max`
|
|
288
|
+
# inside the container still reads "max". Emitting both is safe.
|
|
289
|
+
lines.append(f"lxc.cgroup2.memory.max: {memory * 1048576}")
|
|
290
|
+
if cores is not None:
|
|
291
|
+
# PVE's `cores` sets cpuset affinity only (restrict which CPUs).
|
|
292
|
+
# `cpulimit` is the quota field that translates to cgroup cpu.max,
|
|
293
|
+
# matching plain-LXC's `lxc.cgroup2.cpu.max = N*100000 100000`.
|
|
294
|
+
lines.append(f"cores: {cores}")
|
|
295
|
+
lines.append(f"cpulimit: {cores}")
|
|
296
|
+
lines.append(f"lxc.cgroup2.cpu.max: {cores * 100000} 100000")
|
|
297
|
+
# Pass-through lines (v1.2.0 Phase B, B3): each non-empty line in
|
|
298
|
+
# kento-pve-args is appended verbatim AFTER kento's own lines. PVE's
|
|
299
|
+
# config parser is last-value-wins within the global section, so
|
|
300
|
+
# appending lets the user override kento defaults (e.g. the user
|
|
301
|
+
# writes `memory: 4096` and wins over a kento-emitted `memory: 512`).
|
|
302
|
+
# Denylist in create.py blocks keys kento owns structurally
|
|
303
|
+
# (rootfs:, mp0:, arch:, hostname:, lxc.rootfs.path).
|
|
304
|
+
lines.extend(_read_passthrough_lines(container_dir / "kento-pve-args"))
|
|
305
|
+
return "\n".join(lines) + "\n"
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _read_passthrough_lines(path: Path) -> list[str]:
|
|
309
|
+
"""Return non-empty lines from a kento-pve-args-style file, stripped of
|
|
310
|
+
trailing newlines. Absent file returns an empty list.
|
|
311
|
+
|
|
312
|
+
kento does not parse or validate the contents — the B1 denylist has
|
|
313
|
+
already rejected the structural collisions, and anything else is
|
|
314
|
+
user-authored and kento trusts it.
|
|
315
|
+
"""
|
|
316
|
+
if not path.is_file():
|
|
317
|
+
return []
|
|
318
|
+
out: list[str] = []
|
|
319
|
+
for raw in path.read_text().splitlines():
|
|
320
|
+
line = raw.rstrip("\r")
|
|
321
|
+
if not line:
|
|
322
|
+
continue
|
|
323
|
+
out.append(line)
|
|
324
|
+
return out
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def generate_qm_args(container_dir: Path, *,
|
|
328
|
+
memory: int = 512,
|
|
329
|
+
kvm: bool = True) -> str:
|
|
330
|
+
"""Build the kento-managed `args:` payload for a PVE VM config.
|
|
331
|
+
|
|
332
|
+
Returns the portion that follows ``args: `` — a single space-separated
|
|
333
|
+
line of QEMU arguments. kento assumes exclusive ownership of this
|
|
334
|
+
field: callers mixing in user-supplied args will have those overwritten.
|
|
335
|
+
|
|
336
|
+
The memfd ``size=`` is derived from ``memory`` and must be kept in
|
|
337
|
+
sync with PVE's top-level ``memory:`` field, otherwise the pre-start
|
|
338
|
+
hookscript validator aborts the VM start.
|
|
339
|
+
|
|
340
|
+
Pass-through flags (v1.2.0 Phase B): if ``container_dir/kento-qemu-args``
|
|
341
|
+
exists, each non-empty line is appended space-separated to the args
|
|
342
|
+
payload — kento's own flags first, then pass-through. QEMU honours the
|
|
343
|
+
last occurrence of a flag, so ``--qemu-arg '-m 2048'`` overrides the
|
|
344
|
+
kento-managed defaults. Since ``args:`` is tokenized by qm with simple
|
|
345
|
+
whitespace splitting (NOT shlex), a pass-through line that itself
|
|
346
|
+
contains whitespace is a foot-gun: the user must split it across
|
|
347
|
+
multiple --qemu-arg flags. Raises ValidationError at consumption time.
|
|
348
|
+
|
|
349
|
+
Usermode networking: if ``container_dir/kento-port`` exists (format
|
|
350
|
+
``HOST:GUEST``), a slirp netdev + virtio-net device are injected so a
|
|
351
|
+
pve-vm with ``--network usermode`` gets the same slirp networking +
|
|
352
|
+
host-port forwarding as a plain vm (see vm.py). The device MAC is read
|
|
353
|
+
from ``container_dir/kento-mac`` when present and non-empty. Injected
|
|
354
|
+
before the pass-through loop so a user ``--qemu-arg`` still wins
|
|
355
|
+
(qm honours the last occurrence). For bridge/host/none modes the file
|
|
356
|
+
is absent and nothing is emitted (net0 is handled in generate_qm_config).
|
|
357
|
+
"""
|
|
358
|
+
rootfs = container_dir / "rootfs"
|
|
359
|
+
socket_path = container_dir / "virtiofsd.sock"
|
|
360
|
+
|
|
361
|
+
args_parts = []
|
|
362
|
+
if kvm:
|
|
363
|
+
args_parts.append("-enable-kvm")
|
|
364
|
+
# Nesting (v1.3.0): inject kento's own -cpu. kento-nesting holds "1"
|
|
365
|
+
# (expose vmx/svm for nested accelerated VMs) or "0"/absent (mask them).
|
|
366
|
+
# CPU model is always `host`; nesting OFF deterministically strips vmx/svm.
|
|
367
|
+
# Gated on kvm because the `host` model requires KVM; without it PVE's
|
|
368
|
+
# own default -cpu applies under TCG (matches vm.py's `if VM_KVM` gating).
|
|
369
|
+
# [E2E-VALIDATE] D-strict: assumes PVE accepts a duplicate -cpu in args:
|
|
370
|
+
# and QEMU honors the last one. Fallback if not: D-simple (cpu: host config
|
|
371
|
+
# field). Emitted before the kento-qemu-args pass-through below so a user
|
|
372
|
+
# --qemu-arg '-cpu ...' still wins.
|
|
373
|
+
nesting_file = container_dir / "kento-nesting"
|
|
374
|
+
nesting_on = nesting_file.is_file() and nesting_file.read_text().strip() == "1"
|
|
375
|
+
cpu = "host" if nesting_on else "host,vmx=off,svm=off"
|
|
376
|
+
args_parts.append(f"-cpu {cpu}")
|
|
377
|
+
args_parts += [
|
|
378
|
+
f"-kernel {rootfs}/boot/vmlinuz",
|
|
379
|
+
f"-initrd {rootfs}/boot/initramfs.img",
|
|
380
|
+
'-append "console=ttyS0 rootfstype=virtiofs root=rootfs"',
|
|
381
|
+
"-nographic",
|
|
382
|
+
f"-chardev socket,id=vfs,path={socket_path}",
|
|
383
|
+
"-device vhost-user-fs-pci,chardev=vfs,tag=rootfs",
|
|
384
|
+
f"-object memory-backend-memfd,id=mem,size={memory}M,share=on",
|
|
385
|
+
"-numa node,memdev=mem",
|
|
386
|
+
]
|
|
387
|
+
|
|
388
|
+
# Usermode networking (slirp). Mirrors the plain-vm path in vm.py: when
|
|
389
|
+
# kento-port (HOST:GUEST) is present, emit a slirp netdev with hostfwd plus
|
|
390
|
+
# a virtio-net device. PVE appends this args: line verbatim to the qemu
|
|
391
|
+
# cmdline, so this gives pve-vm --network usermode the same NIC + port
|
|
392
|
+
# forwarding as plain vm. Emitted before the pass-through loop so a user
|
|
393
|
+
# --qemu-arg still wins (qm honours the last occurrence). Each args_parts
|
|
394
|
+
# element is a single "-flag value" pair whose value has no whitespace,
|
|
395
|
+
# satisfying qm's whitespace tokenization.
|
|
396
|
+
port_file = container_dir / "kento-port"
|
|
397
|
+
if port_file.is_file():
|
|
398
|
+
host_port, guest_port = port_file.read_text().strip().split(":")
|
|
399
|
+
device = "virtio-net-pci,netdev=net0"
|
|
400
|
+
mac_file = container_dir / "kento-mac"
|
|
401
|
+
if mac_file.is_file():
|
|
402
|
+
mac = mac_file.read_text().strip()
|
|
403
|
+
if mac:
|
|
404
|
+
device = f"virtio-net-pci,netdev=net0,mac={mac}"
|
|
405
|
+
args_parts += [
|
|
406
|
+
f"-netdev user,id=net0,hostfwd=tcp:127.0.0.1:{host_port}-:{guest_port}",
|
|
407
|
+
f"-device {device}",
|
|
408
|
+
]
|
|
409
|
+
|
|
410
|
+
passthrough_file = container_dir / "kento-qemu-args"
|
|
411
|
+
if passthrough_file.is_file():
|
|
412
|
+
for line in passthrough_file.read_text().splitlines():
|
|
413
|
+
if not line:
|
|
414
|
+
continue
|
|
415
|
+
# qm's args: is whitespace-tokenized with no quoting support,
|
|
416
|
+
# so any whitespace in a single pass-through entry would split
|
|
417
|
+
# into two QEMU flags at boot. Reject explicitly rather than
|
|
418
|
+
# silently mangling the user's intent.
|
|
419
|
+
if any(c.isspace() for c in line):
|
|
420
|
+
raise ValidationError(
|
|
421
|
+
f"kento-qemu-args line contains whitespace which "
|
|
422
|
+
f"qm does not tokenize safely: {line!r}. Split into "
|
|
423
|
+
f"separate --qemu-arg flags instead."
|
|
424
|
+
)
|
|
425
|
+
args_parts.append(line)
|
|
426
|
+
|
|
427
|
+
return " ".join(args_parts)
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def generate_qm_config(name: str, vmid: int, container_dir: Path, *,
|
|
431
|
+
hookscript_ref: str,
|
|
432
|
+
memory: int = 512,
|
|
433
|
+
cores: int = 1,
|
|
434
|
+
machine: str = "q35",
|
|
435
|
+
bridge: str | None = None,
|
|
436
|
+
net_type: str | None = None,
|
|
437
|
+
kvm: bool = True,
|
|
438
|
+
mac: str | None = None) -> str:
|
|
439
|
+
"""Generate a PVE QM config for a kento VM."""
|
|
440
|
+
lines = [
|
|
441
|
+
f"name: {name}",
|
|
442
|
+
"ostype: l26",
|
|
443
|
+
f"machine: {machine}",
|
|
444
|
+
f"memory: {memory}",
|
|
445
|
+
f"cores: {cores}",
|
|
446
|
+
f"hookscript: {hookscript_ref}",
|
|
447
|
+
"serial0: socket",
|
|
448
|
+
]
|
|
449
|
+
|
|
450
|
+
# args: is kento-managed (see generate_qm_args); user-added content
|
|
451
|
+
# would be overwritten by scrub.
|
|
452
|
+
lines.append(f"args: {generate_qm_args(container_dir, memory=memory, kvm=kvm)}")
|
|
453
|
+
|
|
454
|
+
# Network. PVE's net0 format: virtio=<MAC>,bridge=<name>. Include MAC
|
|
455
|
+
# whenever we have one so external DHCP reservations stay stable across
|
|
456
|
+
# recreate/scrub.
|
|
457
|
+
if net_type == "bridge" and bridge:
|
|
458
|
+
if mac:
|
|
459
|
+
lines.append(f"net0: virtio={mac},bridge={bridge}")
|
|
460
|
+
else:
|
|
461
|
+
lines.append(f"net0: virtio,bridge={bridge}")
|
|
462
|
+
|
|
463
|
+
# Pass-through lines (v1.2.0 Phase B, B3): same contract as generate_pve_config.
|
|
464
|
+
# Appended AFTER kento's own lines so last-value-wins hands the user control
|
|
465
|
+
# over duplicate keys (e.g. user-supplied `balloon: 0` overrides nothing,
|
|
466
|
+
# but `cores: 8` would override kento's `cores: <N>` from the create flag).
|
|
467
|
+
lines.extend(_read_passthrough_lines(container_dir / "kento-pve-args"))
|
|
468
|
+
|
|
469
|
+
return "\n".join(lines) + "\n"
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
def _parse_qm_conf_field(content: str, field: str) -> str | None:
|
|
473
|
+
"""Return the value of a top-level qm config field, or None if absent.
|
|
474
|
+
|
|
475
|
+
Matches ``<field>: <value>`` lines in the global section (the section
|
|
476
|
+
before any ``[snapshot]`` header). Returns the last occurrence if the
|
|
477
|
+
field is repeated (shouldn't happen, but shouldn't mask the repeat
|
|
478
|
+
either).
|
|
479
|
+
"""
|
|
480
|
+
value: str | None = None
|
|
481
|
+
for raw in content.splitlines():
|
|
482
|
+
line = raw.rstrip()
|
|
483
|
+
stripped = line.lstrip()
|
|
484
|
+
if stripped.startswith("[") and stripped.endswith("]"):
|
|
485
|
+
break # snapshot section; stop here
|
|
486
|
+
if not stripped or stripped.startswith("#"):
|
|
487
|
+
continue
|
|
488
|
+
if ":" not in stripped:
|
|
489
|
+
continue
|
|
490
|
+
key, sep, val = stripped.partition(":")
|
|
491
|
+
if sep and key.strip() == field:
|
|
492
|
+
value = val.strip()
|
|
493
|
+
return value
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def sync_qm_args_to_memory(vmid: int, container_dir: Path, *,
|
|
497
|
+
kvm: bool = True) -> tuple[int | None, int | None]:
|
|
498
|
+
"""Rewrite the ``args:`` line in the PVE qm config so memfd ``size=``
|
|
499
|
+
matches PVE's ``memory:`` field.
|
|
500
|
+
|
|
501
|
+
Use case: ``qm set <vmid> --memory N`` updates the top-level
|
|
502
|
+
``memory:`` field but leaves the embedded ``size=<N>M`` inside
|
|
503
|
+
``args:`` stale. kento's pre-start hookscript validator catches this
|
|
504
|
+
and refuses to boot; ``kento vm scrub`` calls this helper to rewrite
|
|
505
|
+
args in place so the next start succeeds.
|
|
506
|
+
|
|
507
|
+
Behaviour:
|
|
508
|
+
- If the qm config is missing or has no ``memory:`` field, this is a
|
|
509
|
+
no-op (returns (None, None)).
|
|
510
|
+
- If an ``args:`` line exists it is replaced with a freshly-generated
|
|
511
|
+
one. If it doesn't exist (e.g. externally-stripped config), one is
|
|
512
|
+
appended.
|
|
513
|
+
- PVE's config wins: kento's ``kento-memory`` / ``kento-cores``
|
|
514
|
+
metadata files are rewritten to match the qm config so subsequent
|
|
515
|
+
operations see a consistent value.
|
|
516
|
+
|
|
517
|
+
kento assumes exclusive ownership of ``args:`` — any user-added QEMU
|
|
518
|
+
flags on that line will be overwritten. Workaround: don't edit
|
|
519
|
+
``args:`` directly.
|
|
520
|
+
|
|
521
|
+
Returns (memory, cores) parsed from qm config (either may be None
|
|
522
|
+
if the config doesn't have that field).
|
|
523
|
+
"""
|
|
524
|
+
node = _pve_node_name()
|
|
525
|
+
conf_path = PVE_DIR / "nodes" / node / "qemu-server" / f"{vmid}.conf"
|
|
526
|
+
if not conf_path.is_file():
|
|
527
|
+
return (None, None)
|
|
528
|
+
|
|
529
|
+
content = conf_path.read_text()
|
|
530
|
+
memory_raw = _parse_qm_conf_field(content, "memory")
|
|
531
|
+
cores_raw = _parse_qm_conf_field(content, "cores")
|
|
532
|
+
if memory_raw is None:
|
|
533
|
+
# No memory: field — nothing to sync against.
|
|
534
|
+
return (None, _coerce_int(cores_raw))
|
|
535
|
+
|
|
536
|
+
try:
|
|
537
|
+
memory = int(memory_raw)
|
|
538
|
+
except ValueError:
|
|
539
|
+
return (None, _coerce_int(cores_raw))
|
|
540
|
+
|
|
541
|
+
new_args_line = f"args: {generate_qm_args(container_dir, memory=memory, kvm=kvm)}"
|
|
542
|
+
|
|
543
|
+
# Only the GLOBAL (pre-first-section) `args:` field is kento's to rewrite.
|
|
544
|
+
# PVE stores each snapshot's full config — including its own `args:` line —
|
|
545
|
+
# under a `[<snapname>]` section. Mirror _parse_qm_conf_field's boundary:
|
|
546
|
+
# once we hit the first section header, append the remainder verbatim and
|
|
547
|
+
# stop touching args: (rewriting/dropping snapshot args: corrupts them).
|
|
548
|
+
out_lines: list[str] = []
|
|
549
|
+
replaced = False
|
|
550
|
+
in_global = True
|
|
551
|
+
insert_idx: int | None = None # where to insert if args: never appeared
|
|
552
|
+
for raw in content.splitlines():
|
|
553
|
+
stripped = raw.strip()
|
|
554
|
+
if in_global and stripped.startswith("[") and stripped.endswith("]"):
|
|
555
|
+
# First snapshot header: leave the global region. Remember this
|
|
556
|
+
# spot so an absent global args: lands BEFORE the section, not at
|
|
557
|
+
# EOF (which would fall inside/after a snapshot section).
|
|
558
|
+
in_global = False
|
|
559
|
+
insert_idx = len(out_lines)
|
|
560
|
+
if in_global and raw.startswith("args:"):
|
|
561
|
+
if not replaced:
|
|
562
|
+
out_lines.append(new_args_line)
|
|
563
|
+
replaced = True
|
|
564
|
+
# Drop duplicate global args lines (shouldn't occur, be explicit).
|
|
565
|
+
continue
|
|
566
|
+
out_lines.append(raw)
|
|
567
|
+
if not replaced:
|
|
568
|
+
if insert_idx is not None:
|
|
569
|
+
out_lines.insert(insert_idx, new_args_line)
|
|
570
|
+
else:
|
|
571
|
+
out_lines.append(new_args_line)
|
|
572
|
+
|
|
573
|
+
# Preserve trailing newline behaviour.
|
|
574
|
+
new_content = "\n".join(out_lines)
|
|
575
|
+
if content.endswith("\n") and not new_content.endswith("\n"):
|
|
576
|
+
new_content += "\n"
|
|
577
|
+
conf_path.write_text(new_content)
|
|
578
|
+
|
|
579
|
+
# PVE's config wins: rewrite kento metadata to match so `kento info`,
|
|
580
|
+
# plain-VM fallback, and other consumers agree.
|
|
581
|
+
(container_dir / "kento-memory").write_text(f"{memory}\n")
|
|
582
|
+
cores = _coerce_int(cores_raw)
|
|
583
|
+
if cores is not None:
|
|
584
|
+
(container_dir / "kento-cores").write_text(f"{cores}\n")
|
|
585
|
+
|
|
586
|
+
return (memory, cores)
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def _coerce_int(value: str | None) -> int | None:
|
|
590
|
+
if value is None:
|
|
591
|
+
return None
|
|
592
|
+
try:
|
|
593
|
+
return int(value)
|
|
594
|
+
except ValueError:
|
|
595
|
+
return None
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def write_qm_config(vmid: int, content: str) -> Path:
|
|
599
|
+
"""Write a QM config to /etc/pve/nodes/<node>/qemu-server/<VMID>.conf.
|
|
600
|
+
|
|
601
|
+
Same pmxcfs mkdir pattern as write_pve_config().
|
|
602
|
+
"""
|
|
603
|
+
node = _pve_node_name()
|
|
604
|
+
conf_dir = PVE_DIR / "nodes" / node / "qemu-server"
|
|
605
|
+
for parent in [PVE_DIR / "nodes", PVE_DIR / "nodes" / node, conf_dir]:
|
|
606
|
+
try:
|
|
607
|
+
parent.mkdir()
|
|
608
|
+
except FileExistsError:
|
|
609
|
+
pass
|
|
610
|
+
conf_path = conf_dir / f"{vmid}.conf"
|
|
611
|
+
conf_path.write_text(content)
|
|
612
|
+
return conf_path
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def delete_qm_config(vmid: int) -> None:
|
|
616
|
+
"""Delete a QM config. No error if the file doesn't exist."""
|
|
617
|
+
node = _pve_node_name()
|
|
618
|
+
conf_path = PVE_DIR / "nodes" / node / "qemu-server" / f"{vmid}.conf"
|
|
619
|
+
conf_path.unlink(missing_ok=True)
|