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/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)