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/set_cmd.py ADDED
@@ -0,0 +1,1036 @@
1
+ """Change scalar settings on a STOPPED kento-managed instance.
2
+
3
+ `kento set` mutates a handful of scalar config fields (memory, cores, mac,
4
+ and the QEMU / PVE pass-through lists) in place and re-emits the affected
5
+ config. Changes take effect on the instance's NEXT start.
6
+
7
+ Design (see plans/set-suspend-resume.md "LOCKED design"): the kento metadata
8
+ files under ``container_dir`` are the source of truth. A full config regen is
9
+ NOT viable because net_type/bridge are not persisted (generate_pve_config /
10
+ generate_qm_config could not be faithfully re-called), so instead we write the
11
+ metadata file(s) and then surgically re-emit ONLY the kento-owned scalar lines
12
+ in the native ``config`` / PVE ``.conf`` / qm ``.conf``, preserving every
13
+ structural and network line.
14
+
15
+ Field validity (mode strings: "lxc", "pve" (pve-lxc!), "vm", "pve-vm"):
16
+ memory / cores : all four modes.
17
+ mac / qemu_args: VM modes only (vm, pve-vm).
18
+ pve_args : PVE modes only (pve, pve-vm).
19
+ lxc_args : plain LXC only ("lxc").
20
+
21
+ List semantics for qemu_args / pve_args (argparse action="append",
22
+ default=None):
23
+ - None (flag absent) -> leave the stored file untouched.
24
+ - >=1 non-empty entry -> REPLACE the file with those entries.
25
+ - provided but all entries empty ('')-> CLEAR (unlink the metadata file).
26
+ """
27
+
28
+ import logging
29
+ import re
30
+ from pathlib import Path
31
+
32
+ from kento import is_running, require_root, resolve_any
33
+ from kento.defaults import (LXC_ARG_DENYLIST, PVE_ARG_DENYLIST,
34
+ QEMU_ARG_DENYLIST)
35
+ from kento.errors import ModeError, StateError, ValidationError
36
+
37
+ logger = logging.getLogger("kento")
38
+
39
+ _MAC_RE = re.compile(r"^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$")
40
+ _PORT_RE = re.compile(r"^\d+:\d+$")
41
+
42
+ # net_type values that support port forwarding, by family. Usermode forwards
43
+ # via QEMU slirp hostfwd (VM modes); bridge forwards via iptables/nft DNAT on
44
+ # the host (LXC/PVE). host/none have no per-instance NIC to forward to.
45
+ _PORT_TYPES = ("usermode", "bridge")
46
+
47
+
48
+ def _classify_list(raw: list[str] | None) -> str:
49
+ """Return one of "skip", "clear", "replace" for a pass-through list arg."""
50
+ if raw is None:
51
+ return "skip"
52
+ provided = [x for x in raw if x != ""]
53
+ return "replace" if provided else "clear"
54
+
55
+
56
+ def _nonempty(raw: list[str]) -> list[str]:
57
+ return [x for x in raw if x != ""]
58
+
59
+
60
+ def _replace_conf_field(content: str, field: str, value: str | None) -> str:
61
+ """Replace the LAST ``<field>: ...`` line in the global section.
62
+
63
+ The global section is everything before the first ``[section]`` header.
64
+ If ``value`` is None the field line is removed; otherwise the existing
65
+ line is replaced in place (or appended at the end of the global section
66
+ if absent). Mirrors pve._parse_qm_conf_field's section handling and
67
+ sync_qm_args_to_memory's rewrite loop.
68
+ """
69
+ lines = content.splitlines()
70
+
71
+ # Find section boundary (first [header]) and the index of the last
72
+ # matching field line within the global section.
73
+ section_start = len(lines)
74
+ last_idx = -1
75
+ for i, raw in enumerate(lines):
76
+ stripped = raw.strip()
77
+ if stripped.startswith("[") and stripped.endswith("]"):
78
+ section_start = i
79
+ break
80
+ if not stripped or stripped.startswith("#"):
81
+ continue
82
+ if ":" not in stripped:
83
+ continue
84
+ key, sep, _ = stripped.partition(":")
85
+ if sep and key.strip() == field:
86
+ last_idx = i
87
+
88
+ new_line = None if value is None else f"{field}: {value}"
89
+
90
+ if last_idx >= 0:
91
+ if new_line is None:
92
+ del lines[last_idx]
93
+ else:
94
+ lines[last_idx] = new_line
95
+ elif new_line is not None:
96
+ # Append at the end of the global section.
97
+ lines.insert(section_start, new_line)
98
+
99
+ out = "\n".join(lines)
100
+ if content.endswith("\n") and not out.endswith("\n"):
101
+ out += "\n"
102
+ return out
103
+
104
+
105
+ def _replace_config_raw(content: str, key: str, value: str | None) -> str:
106
+ """Replace a native LXC ``config`` line of the form ``<key> = <value>``.
107
+
108
+ These use ``key = value`` (spaces around ``=``), unlike PVE's ``key:``.
109
+ Replaces the last occurrence, appends if absent, removes if value None.
110
+ """
111
+ lines = content.splitlines()
112
+ last_idx = -1
113
+ for i, raw in enumerate(lines):
114
+ stripped = raw.strip()
115
+ if not stripped or stripped.startswith("#"):
116
+ continue
117
+ if "=" not in stripped:
118
+ continue
119
+ k = stripped.split("=", 1)[0].strip()
120
+ if k == key:
121
+ last_idx = i
122
+
123
+ new_line = None if value is None else f"{key} = {value}"
124
+ if last_idx >= 0:
125
+ if new_line is None:
126
+ del lines[last_idx]
127
+ else:
128
+ lines[last_idx] = new_line
129
+ elif new_line is not None:
130
+ lines.append(new_line)
131
+
132
+ out = "\n".join(lines)
133
+ if content.endswith("\n") and not out.endswith("\n"):
134
+ out += "\n"
135
+ return out
136
+
137
+
138
+ def _drop_passthrough_block(content: str, known_lines: list[str]) -> str:
139
+ """Remove kento-pve-args pass-through lines previously appended to a conf.
140
+
141
+ The pass-through block is appended verbatim after kento's own lines.
142
+ We remove any line in the global section that exactly matches one of the
143
+ currently-stored pass-through lines. (Called BEFORE the metadata file is
144
+ overwritten so ``known_lines`` reflects the old block.)
145
+ """
146
+ if not known_lines:
147
+ return content
148
+ known = set(known_lines)
149
+ lines = content.splitlines()
150
+ kept = []
151
+ in_global = True
152
+ for raw in lines:
153
+ stripped = raw.strip()
154
+ if stripped.startswith("[") and stripped.endswith("]"):
155
+ in_global = False
156
+ if in_global and raw in known:
157
+ continue
158
+ kept.append(raw)
159
+ out = "\n".join(kept)
160
+ if content.endswith("\n") and not out.endswith("\n"):
161
+ out += "\n"
162
+ return out
163
+
164
+
165
+ # ---------------------------------------------------------------------------
166
+ # Network identity: resolve-current -> apply-delta -> re-emit
167
+ # ---------------------------------------------------------------------------
168
+
169
+ def _read_line(container_dir: Path, field: str) -> str | None:
170
+ p = container_dir / f"kento-{field}"
171
+ if p.is_file():
172
+ v = p.read_text().strip()
173
+ return v or None
174
+ return None
175
+
176
+
177
+ def _parse_kento_net(container_dir: Path) -> dict:
178
+ """Parse the kento-net metadata file (ip=/gateway=/dns=/searchdomain=)."""
179
+ out = {"ip": None, "gateway": None, "dns": None, "searchdomain": None}
180
+ p = container_dir / "kento-net"
181
+ if not p.is_file():
182
+ return out
183
+ for line in p.read_text().strip().splitlines():
184
+ if "=" not in line:
185
+ continue
186
+ k, v = line.split("=", 1)
187
+ if k in out:
188
+ out[k] = v or None
189
+ return out
190
+
191
+
192
+ def _parse_lxc_net_fallback(container_dir: Path) -> dict:
193
+ """Derive net identity from a plain-LXC native ``config`` (pre-N1)."""
194
+ out = {"type": "none", "bridge": None, "ip": None, "gateway": None}
195
+ config = container_dir / "config"
196
+ if not config.is_file():
197
+ return out
198
+ net_type = None
199
+ for raw in config.read_text().splitlines():
200
+ s = raw.strip()
201
+ if "=" not in s or s.startswith("#"):
202
+ continue
203
+ k, v = (x.strip() for x in s.split("=", 1))
204
+ if k == "lxc.net.0.type":
205
+ net_type = v
206
+ elif k == "lxc.net.0.link":
207
+ out["bridge"] = v
208
+ elif k == "lxc.net.0.ipv4.address":
209
+ out["ip"] = v
210
+ elif k == "lxc.net.0.ipv4.gateway":
211
+ out["gateway"] = v
212
+ if net_type == "veth" and out["bridge"]:
213
+ out["type"] = "bridge"
214
+ elif net_type == "none":
215
+ out["type"] = "host"
216
+ else:
217
+ out["type"] = "none"
218
+ return out
219
+
220
+
221
+ def _parse_pve_net_fallback(container_dir: Path, mode: str) -> dict:
222
+ """Derive net identity from a PVE .conf (pre-N1)."""
223
+ out = {"type": "none", "bridge": None, "ip": None, "gateway": None}
224
+ try:
225
+ if mode == "pve":
226
+ conf_path, _ = _pve_lxc_conf_path(container_dir)
227
+ else:
228
+ conf_path, _ = _pve_vm_conf_path(container_dir)
229
+ except Exception:
230
+ return out
231
+ if not conf_path.is_file():
232
+ return out
233
+ content = conf_path.read_text()
234
+ if "lxc.net.0.type: none" in content:
235
+ out["type"] = "host"
236
+ return out
237
+ from kento.pve import _parse_qm_conf_field
238
+ net0 = _parse_qm_conf_field(content, "net0")
239
+ if net0 is None:
240
+ # pve-vm usermode has no net0; treat as usermode for pve-vm else none.
241
+ out["type"] = "usermode" if mode == "pve-vm" else "none"
242
+ return out
243
+ parts = dict(
244
+ p.split("=", 1) for p in net0.split(",") if "=" in p)
245
+ if "bridge" in parts:
246
+ out["type"] = "bridge"
247
+ out["bridge"] = parts["bridge"]
248
+ if parts.get("ip") and parts["ip"] != "dhcp":
249
+ out["ip"] = parts["ip"]
250
+ if parts.get("gw"):
251
+ out["gateway"] = parts["gw"]
252
+ return out
253
+
254
+
255
+ def _resolve_net_identity(container_dir: Path, mode: str) -> dict:
256
+ """Return the CURRENT network identity of an instance.
257
+
258
+ Keys: type, bridge, ip, gateway, dns, searchdomain, port, hostname.
259
+
260
+ Prefers kento metadata (kento-net-type / kento-bridge / kento-net /
261
+ kento-port / kento-hostname). For pre-N1 instances that predate
262
+ kento-net-type, falls back to parsing the live config to derive
263
+ type/bridge/ip/gateway. Defensive throughout — unknowns default sensibly.
264
+ """
265
+ net = _parse_kento_net(container_dir)
266
+ ident = {
267
+ "type": _read_line(container_dir, "net-type"),
268
+ "bridge": _read_line(container_dir, "bridge"),
269
+ "ip": net["ip"],
270
+ "gateway": net["gateway"],
271
+ "dns": net["dns"],
272
+ "searchdomain": net["searchdomain"],
273
+ "port": _read_line(container_dir, "port"),
274
+ "hostname": (_read_line(container_dir, "hostname")
275
+ or _read_line(container_dir, "name")
276
+ or container_dir.name),
277
+ }
278
+
279
+ if ident["type"] is None:
280
+ # Pre-N1: no recorded type. Parse the live config.
281
+ if mode == "lxc":
282
+ fb = _parse_lxc_net_fallback(container_dir)
283
+ elif mode in ("pve", "pve-vm"):
284
+ fb = _parse_pve_net_fallback(container_dir, mode)
285
+ else: # plain vm: no bridge possible; usermode is the create default.
286
+ fb = {"type": "usermode", "bridge": None,
287
+ "ip": None, "gateway": None}
288
+ ident["type"] = fb["type"]
289
+ if ident["bridge"] is None:
290
+ ident["bridge"] = fb["bridge"]
291
+ if ident["ip"] is None:
292
+ ident["ip"] = fb["ip"]
293
+ if ident["gateway"] is None:
294
+ ident["gateway"] = fb["gateway"]
295
+
296
+ return ident
297
+
298
+
299
+ def _parse_network_arg(network: str | None) -> tuple[str | None, str | None]:
300
+ """Parse a --network value into (net_type, bridge_name).
301
+
302
+ Accepts the same surface as create's --network: "bridge",
303
+ "bridge=<name>", "host", "usermode", "none". Returns (None, None) when
304
+ no --network was given. Mode-vs-type validity is enforced later against
305
+ the resolved identity (so the message reflects the actual instance).
306
+ """
307
+ if network is None:
308
+ return None, None
309
+ if network in ("host", "usermode", "none", "bridge"):
310
+ return network, None
311
+ if network.startswith("bridge="):
312
+ name = network.split("=", 1)[1]
313
+ if not name:
314
+ raise ValidationError(
315
+ "--network bridge=<name> requires a bridge name."
316
+ )
317
+ return "bridge", name
318
+ raise ValidationError(
319
+ f"unknown --network value {network!r}; expected one of "
320
+ "bridge, bridge=<name>, host, usermode, none."
321
+ )
322
+
323
+
324
+ def _net_state_dir(container_dir: Path) -> Path | None:
325
+ p = container_dir / "kento-state"
326
+ if p.is_file():
327
+ return Path(p.read_text().strip())
328
+ return None
329
+
330
+
331
+ def _validate_net_identity(new: dict, *, mode: str, ip_provided: bool,
332
+ gateway_provided: bool, port_action: str) -> None:
333
+ """Validate a fully-resolved NEW identity against the mode matrix.
334
+
335
+ Raises ModeError / ValidationError. Called BEFORE any mutation.
336
+ """
337
+ t = new["type"]
338
+ vm_modes = ("vm", "pve-vm")
339
+ bridge_ok = ("lxc", "pve", "pve-vm") # NOT plain vm (no tap in start_vm)
340
+ usermode_ok = ("vm", "pve-vm")
341
+ host_ok = ("lxc", "pve")
342
+
343
+ if t == "bridge" and mode not in bridge_ok:
344
+ raise ModeError(
345
+ "--network bridge is not supported for plain VM "
346
+ "(no host bridge attach in the plain-VM start path; use pve-vm "
347
+ "for bridged VM networking, or --network usermode)."
348
+ )
349
+ if t == "usermode" and mode not in usermode_ok:
350
+ raise ModeError(
351
+ "--network usermode is only supported for VM modes (vm, pve-vm); "
352
+ f"{mode} uses bridge/host/none networking."
353
+ )
354
+ if t == "host" and mode not in host_ok:
355
+ raise ModeError(
356
+ "--network host is not supported for VM modes; a VM cannot share "
357
+ "the host network namespace. Use usermode, bridge (pve-vm), or none."
358
+ )
359
+ # 'none' is valid in every mode.
360
+
361
+ # ip/gateway require bridge networking.
362
+ if ip_provided and new["ip"] is not None and t != "bridge":
363
+ raise ValidationError(
364
+ "--ip requires bridge networking (--network bridge=<name>); "
365
+ f"the resulting network type is {t!r}."
366
+ )
367
+ if gateway_provided and new["ip"] is None:
368
+ raise ValidationError(
369
+ "--gateway requires a static --ip (a gateway has no meaning "
370
+ "without a static address)."
371
+ )
372
+
373
+ # port: only meaningful where there's a forwardable NIC.
374
+ if port_action == "replace" and t not in _PORT_TYPES:
375
+ raise ModeError(
376
+ f"--port is not supported for --network {t!r}; port forwarding "
377
+ "requires usermode (VM) or bridge (LXC/PVE DNAT) networking."
378
+ )
379
+
380
+
381
+ def set_cmd(name, *, memory=None, cores=None, mac=None,
382
+ qemu_args=None, pve_args=None, lxc_args=None,
383
+ network=None, ip=None, gateway=None, dns=None,
384
+ hostname=None, port=None,
385
+ namespace=None) -> int:
386
+ """Mutate scalar settings on a stopped instance. Returns 0 on success, raises on error."""
387
+ require_root()
388
+
389
+ container_dir, mode = resolve_any(name, namespace)
390
+
391
+ # No fields at all -> usage error.
392
+ if (memory is None and cores is None and mac is None
393
+ and qemu_args is None and pve_args is None and lxc_args is None
394
+ and network is None and ip is None and gateway is None
395
+ and dns is None and hostname is None and port is None):
396
+ raise ValidationError(
397
+ "nothing to set. Provide at least one of --memory, "
398
+ "--cores, --mac, --qemu-arg, --pve-arg, --lxc-arg, --network, "
399
+ "--ip, --gateway, --dns, --hostname, --port."
400
+ )
401
+
402
+ if is_running(container_dir, mode):
403
+ raise StateError(
404
+ f"instance is running. Stop it first: kento stop {name}"
405
+ )
406
+
407
+ # --- Field validity (error BEFORE mutating anything) ---
408
+ vm_modes = ("vm", "pve-vm")
409
+ pve_modes = ("pve", "pve-vm")
410
+
411
+ if mac is not None and mode not in vm_modes:
412
+ raise ModeError(
413
+ "--mac is not supported for LXC/PVE-LXC instances (no virtio NIC)."
414
+ )
415
+ if qemu_args is not None and mode not in vm_modes:
416
+ raise ModeError(
417
+ "--qemu-arg is not supported for LXC/PVE-LXC instances; "
418
+ "it applies to VM modes only."
419
+ )
420
+ if pve_args is not None and mode not in pve_modes:
421
+ if mode == "lxc":
422
+ raise ModeError(
423
+ "--pve-arg is not supported for plain LXC; it appends "
424
+ "lines to the PVE lxc config and only applies on PVE hosts. "
425
+ "Plain LXC has no PVE config. For plain-LXC native config "
426
+ "pass-through use --lxc-arg."
427
+ )
428
+ else: # vm
429
+ raise ModeError(
430
+ "--pve-arg is not supported for plain VM; it appends "
431
+ "lines to the PVE qm config and only applies on PVE hosts. "
432
+ "Plain VM has no PVE config."
433
+ )
434
+ if lxc_args is not None and mode != "lxc":
435
+ if mode == "pve":
436
+ raise ModeError(
437
+ "--lxc-arg is not supported on a PVE host. On PVE the "
438
+ "LXC config is the PVE config; use --pve-arg, which carries "
439
+ "raw lxc.* lines."
440
+ )
441
+ else: # vm / pve-vm
442
+ raise ModeError(
443
+ "--lxc-arg is not applicable to VM modes (no native LXC config)."
444
+ )
445
+
446
+ # Denylist: reject --lxc-arg values that collide with kento's structural
447
+ # plain-LXC config lines (mirrors create.py's _validate_lxc_args).
448
+ if lxc_args is not None:
449
+ for arg in lxc_args:
450
+ for needle in LXC_ARG_DENYLIST:
451
+ if needle in arg:
452
+ raise ValidationError(
453
+ f"kento manages {needle!r} directly — "
454
+ f"--lxc-arg {arg!r} would collide with kento's own "
455
+ "plain-LXC config. Drop the flag or file an issue "
456
+ "if you need it overridable."
457
+ )
458
+
459
+ # Same denylist enforcement create.py applies for --qemu-arg / --pve-arg
460
+ # (create/set parity): without these, `kento set` would re-emit a
461
+ # denylisted token into the boot config, clobbering kento-owned keys or
462
+ # duplicating -kernel/memfd etc. Empty-string entries are the CLEAR
463
+ # sentinel and are skipped (mirrors the lxc loop, where '' never matches).
464
+ if qemu_args is not None:
465
+ for arg in qemu_args:
466
+ if arg == "":
467
+ continue
468
+ for needle in QEMU_ARG_DENYLIST:
469
+ if needle in arg:
470
+ raise ValidationError(
471
+ f"kento manages {needle!r} directly — "
472
+ f"--qemu-arg {arg!r} would collide with kento's own "
473
+ "QEMU argv. Drop the flag or file an issue if you "
474
+ "need it overridable."
475
+ )
476
+
477
+ if pve_args is not None:
478
+ for arg in pve_args:
479
+ if arg == "":
480
+ continue
481
+ for needle in PVE_ARG_DENYLIST:
482
+ if needle in arg:
483
+ raise ValidationError(
484
+ f"kento manages {needle!r} directly — "
485
+ f"--pve-arg {arg!r} would collide with kento's own "
486
+ "PVE config. Drop the flag or file an issue if you "
487
+ "need it overridable."
488
+ )
489
+
490
+ if mac is not None and not _MAC_RE.match(mac):
491
+ raise ValidationError(
492
+ f"invalid MAC address {mac!r}; expected format XX:XX:XX:XX:XX:XX."
493
+ )
494
+ if memory is not None and memory <= 0:
495
+ raise ValidationError(
496
+ f"--memory must be a positive integer (MB), got {memory}."
497
+ )
498
+ if cores is not None and cores <= 0:
499
+ raise ValidationError(
500
+ f"--cores must be a positive integer, got {cores}."
501
+ )
502
+
503
+ # --- Network delta: resolve-current -> apply-overrides -> validate ---
504
+ net_provided = (network is not None or ip is not None or gateway is not None
505
+ or dns is not None or hostname is not None
506
+ or port is not None)
507
+ net_delta = None
508
+ if net_provided:
509
+ # Parse --network "type[/bridge]" sentinel into (net_type, bridge).
510
+ # The CLI passes a pre-parsed value "type" or "type=bridgename".
511
+ req_type, req_bridge = _parse_network_arg(network)
512
+
513
+ current = _resolve_net_identity(container_dir, mode)
514
+ new = dict(current)
515
+ if req_type is not None:
516
+ new["type"] = req_type
517
+ # A network type change resets the bridge unless one was supplied.
518
+ if req_type == "bridge":
519
+ if req_bridge is not None:
520
+ new["bridge"] = req_bridge
521
+ # else keep the current bridge (may be None -> emitters guard)
522
+ else:
523
+ new["bridge"] = None
524
+ elif req_bridge is not None:
525
+ new["bridge"] = req_bridge
526
+
527
+ if ip is not None:
528
+ if ip == "dhcp":
529
+ new["ip"] = None
530
+ new["gateway"] = None # gateway is meaningless without static ip
531
+ else:
532
+ new["ip"] = ip
533
+ if gateway is not None:
534
+ new["gateway"] = gateway or None
535
+ if dns is not None:
536
+ new["dns"] = dns or None
537
+ if hostname is not None:
538
+ new["hostname"] = hostname
539
+
540
+ port_action = _classify_list(port)
541
+ if port_action == "replace":
542
+ for entry in _nonempty(port):
543
+ if not _PORT_RE.match(entry):
544
+ raise ValidationError(
545
+ f"invalid --port {entry!r}; expected HOST:GUEST with "
546
+ "numeric ports (e.g. 10022:22)."
547
+ )
548
+
549
+ _validate_net_identity(
550
+ new, mode=mode,
551
+ ip_provided=ip is not None,
552
+ gateway_provided=gateway is not None,
553
+ port_action=port_action,
554
+ )
555
+ net_delta = {"new": new, "port": port, "port_action": port_action,
556
+ "ip_provided": ip is not None, "dns_provided": dns is not None,
557
+ "hostname_provided": hostname is not None,
558
+ "ip_value": ip}
559
+
560
+ # --- Apply per mode ---
561
+ if mode == "vm":
562
+ _apply_vm(container_dir, memory, cores, mac, qemu_args, net_delta)
563
+ elif mode == "lxc":
564
+ _apply_lxc(container_dir, memory, cores, lxc_args, net_delta)
565
+ elif mode == "pve":
566
+ _apply_pve_lxc(container_dir, memory, cores, pve_args, net_delta)
567
+ elif mode == "pve-vm":
568
+ _apply_pve_vm(container_dir, memory, cores, mac, qemu_args, pve_args,
569
+ net_delta)
570
+
571
+ logger.info("Updated: %s", name)
572
+ logger.info(" Changes take effect on next start.")
573
+ return 0
574
+
575
+
576
+ def _write_meta(container_dir: Path, field: str, value) -> None:
577
+ (container_dir / f"kento-{field}").write_text(str(value) + "\n")
578
+
579
+
580
+ def _persist_net_meta(container_dir: Path, new: dict) -> None:
581
+ """Write kento-net-type / kento-bridge / kento-net from the NEW identity."""
582
+ _write_meta(container_dir, "net-type", new["type"])
583
+ bridge_file = container_dir / "kento-bridge"
584
+ if new["type"] == "bridge" and new.get("bridge"):
585
+ bridge_file.write_text(new["bridge"] + "\n")
586
+ else:
587
+ bridge_file.unlink(missing_ok=True)
588
+
589
+ # kento-net holds the static-only lines (ip/gateway) plus dns/searchdomain.
590
+ net_parts = []
591
+ if new.get("ip"):
592
+ net_parts.append(f"ip={new['ip']}")
593
+ if new.get("gateway"):
594
+ net_parts.append(f"gateway={new['gateway']}")
595
+ if new.get("dns"):
596
+ net_parts.append(f"dns={new['dns']}")
597
+ if new.get("searchdomain"):
598
+ net_parts.append(f"searchdomain={new['searchdomain']}")
599
+ net_file = container_dir / "kento-net"
600
+ if net_parts:
601
+ net_file.write_text("\n".join(net_parts) + "\n")
602
+ else:
603
+ net_file.unlink(missing_ok=True)
604
+
605
+
606
+ def _emit_guest_net_dropins(container_dir: Path, new: dict, mode: str) -> None:
607
+ """Re-emit the overlay guest drop-ins (05-static / 90-resolved / hostname).
608
+
609
+ Mirrors create.py: a static ip writes 05-kento-static.network (and removes
610
+ any stale 90-kento.conf); dns-only writes 90-kento.conf; neither -> remove
611
+ both. hostname always writes /etc/hostname. Needs kento-state.
612
+ """
613
+ from kento.create import _inject_hostname, _inject_network_config
614
+
615
+ state_dir = _net_state_dir(container_dir)
616
+ if state_dir is None:
617
+ # No overlay (rare); guest drop-ins are best-effort.
618
+ return
619
+
620
+ net_dir = state_dir / "upper" / "etc" / "systemd" / "network"
621
+ static = net_dir / "05-kento-static.network"
622
+ resolved_dir = state_dir / "upper" / "etc" / "systemd" / "resolved.conf.d"
623
+ resolved = resolved_dir / "90-kento.conf"
624
+
625
+ if new.get("ip"):
626
+ _inject_network_config(state_dir, new["ip"], new.get("gateway"),
627
+ new.get("dns"), new.get("searchdomain"),
628
+ mode=mode)
629
+ resolved.unlink(missing_ok=True)
630
+ else:
631
+ static.unlink(missing_ok=True)
632
+ if new.get("dns") or new.get("searchdomain"):
633
+ resolved_dir.mkdir(parents=True, exist_ok=True)
634
+ lines = ["[Resolve]"]
635
+ if new.get("dns"):
636
+ lines.append(f"DNS={new['dns']}")
637
+ if new.get("searchdomain"):
638
+ lines.append(f"Domains={new['searchdomain']}")
639
+ lines.append("")
640
+ resolved.write_text("\n".join(lines))
641
+ else:
642
+ resolved.unlink(missing_ok=True)
643
+
644
+ if new.get("hostname"):
645
+ _inject_hostname(state_dir, new["hostname"])
646
+ _write_meta(container_dir, "hostname", new["hostname"])
647
+
648
+
649
+ def _apply_port_meta(container_dir: Path, port, port_action: str) -> None:
650
+ """Replace/clear kento-port from a --port list arg."""
651
+ path = container_dir / "kento-port"
652
+ if port_action == "clear":
653
+ path.unlink(missing_ok=True)
654
+ elif port_action == "replace":
655
+ # set takes a single forwarding pair (mirrors create's --port).
656
+ path.write_text(_nonempty(port)[0] + "\n")
657
+
658
+
659
+ def _apply_passthrough_meta(container_dir: Path, field: str,
660
+ raw: list[str] | None) -> str:
661
+ """Apply replace/clear/skip to a kento-<field> metadata file.
662
+
663
+ Returns the action taken ("skip"/"clear"/"replace").
664
+ """
665
+ action = _classify_list(raw)
666
+ path = container_dir / f"kento-{field}"
667
+ if action == "clear":
668
+ path.unlink(missing_ok=True)
669
+ elif action == "replace":
670
+ path.write_text("\n".join(_nonempty(raw)) + "\n")
671
+ return action
672
+
673
+
674
+ # ---------------------------------------------------------------------------
675
+ # Plain VM: metadata only (vm.py reads memory/cores/mac/qemu_args at start).
676
+ # ---------------------------------------------------------------------------
677
+
678
+ def _apply_vm(container_dir, memory, cores, mac, qemu_args,
679
+ net_delta=None) -> None:
680
+ if memory is not None:
681
+ _write_meta(container_dir, "memory", memory)
682
+ if cores is not None:
683
+ _write_meta(container_dir, "cores", cores)
684
+ if mac is not None:
685
+ _write_meta(container_dir, "mac", mac)
686
+ _apply_passthrough_meta(container_dir, "qemu-args", qemu_args)
687
+
688
+ if net_delta is not None:
689
+ new = net_delta["new"]
690
+ # Plain VM: only usermode/none reach here (validated). NIC + port are
691
+ # read from kento-port at start (vm.py); ip/gateway already rejected.
692
+ _apply_port_meta(container_dir, net_delta["port"],
693
+ net_delta["port_action"])
694
+ # The slirp NIC is usermode-only (vm.py emits it purely on kento-port).
695
+ # Switching to none must drop kento-port, else the VM keeps a slirp NIC.
696
+ if new["type"] != "usermode":
697
+ (container_dir / "kento-port").unlink(missing_ok=True)
698
+ _emit_guest_net_dropins(container_dir, new, "vm")
699
+ _persist_net_meta(container_dir, new)
700
+
701
+
702
+ # ---------------------------------------------------------------------------
703
+ # Plain LXC: metadata + patch native `config` cgroup lines.
704
+ # ---------------------------------------------------------------------------
705
+
706
+ def _rewrite_lxc_net(content: str, new: dict) -> str:
707
+ """Rewrite the lxc.net.0.* block to match the NEW identity.
708
+
709
+ bridge -> type=veth + link + flags=up (+ ipv4.address/gateway if static);
710
+ host -> type=none, no link/flags/ipv4;
711
+ none -> remove all lxc.net.0.* lines.
712
+ """
713
+ keys = ("lxc.net.0.type", "lxc.net.0.link", "lxc.net.0.flags",
714
+ "lxc.net.0.ipv4.address", "lxc.net.0.ipv4.gateway")
715
+ # Start from a clean slate: drop every existing net.0 line, then re-add.
716
+ for k in keys:
717
+ content = _replace_config_raw(content, k, None)
718
+
719
+ t = new["type"]
720
+ if t == "bridge" and new.get("bridge"):
721
+ content = _replace_config_raw(content, "lxc.net.0.type", "veth")
722
+ content = _replace_config_raw(content, "lxc.net.0.link", new["bridge"])
723
+ content = _replace_config_raw(content, "lxc.net.0.flags", "up")
724
+ if new.get("ip"):
725
+ content = _replace_config_raw(
726
+ content, "lxc.net.0.ipv4.address", new["ip"])
727
+ if new.get("gateway"):
728
+ content = _replace_config_raw(
729
+ content, "lxc.net.0.ipv4.gateway", new["gateway"])
730
+ elif t == "host":
731
+ content = _replace_config_raw(content, "lxc.net.0.type", "none")
732
+ # t == "none": leave all removed.
733
+ return content
734
+
735
+
736
+ def _apply_lxc(container_dir, memory, cores, lxc_args=None,
737
+ net_delta=None) -> None:
738
+ from kento.pve import _read_passthrough_lines
739
+
740
+ config_path = container_dir / "config"
741
+ content = config_path.read_text() if config_path.is_file() else ""
742
+ changed = False
743
+ if memory is not None:
744
+ _write_meta(container_dir, "memory", memory)
745
+ content = _replace_config_raw(
746
+ content, "lxc.cgroup2.memory.max", str(memory * 1048576))
747
+ changed = True
748
+ if cores is not None:
749
+ _write_meta(container_dir, "cores", cores)
750
+ content = _replace_config_raw(
751
+ content, "lxc.cgroup2.cpu.max", f"{cores * 100000} 100000")
752
+ changed = True
753
+
754
+ # lxc_args: drop the OLD pass-through block (read before overwriting the
755
+ # metadata file), update/clear kento-lxc-args, then re-append the new
756
+ # block at the end of `config`. Mirrors _apply_pve_lxc's pve_args path.
757
+ lxc_action = _classify_list(lxc_args)
758
+ if lxc_action != "skip":
759
+ old_block = _read_passthrough_lines(container_dir / "kento-lxc-args")
760
+ content = _drop_passthrough_block(content, old_block)
761
+ changed = True
762
+ if lxc_action == "clear":
763
+ (container_dir / "kento-lxc-args").unlink(missing_ok=True)
764
+ else: # replace
765
+ (container_dir / "kento-lxc-args").write_text(
766
+ "\n".join(_nonempty(lxc_args)) + "\n")
767
+ new_block = _read_passthrough_lines(container_dir / "kento-lxc-args")
768
+ body = content.rstrip("\n")
769
+ content = body + "\n" + "\n".join(new_block) + "\n"
770
+
771
+ if net_delta is not None:
772
+ new = net_delta["new"]
773
+ content = _rewrite_lxc_net(content, new)
774
+ changed = True
775
+ _apply_port_meta(container_dir, net_delta["port"],
776
+ net_delta["port_action"])
777
+ _emit_guest_net_dropins(container_dir, new, "lxc")
778
+ _persist_net_meta(container_dir, new)
779
+
780
+ if changed:
781
+ config_path.write_text(content)
782
+
783
+
784
+ # ---------------------------------------------------------------------------
785
+ # PVE-LXC: metadata + surgical rewrite of the PVE .conf.
786
+ # ---------------------------------------------------------------------------
787
+
788
+ def _pve_lxc_conf_path(container_dir: Path):
789
+ from kento.pve import PVE_DIR, _pve_node_name
790
+ vmid_file = container_dir / "kento-vmid"
791
+ vmid = (vmid_file.read_text().strip() if vmid_file.is_file()
792
+ else container_dir.name)
793
+ node = _pve_node_name()
794
+ return PVE_DIR / "nodes" / node / "lxc" / f"{vmid}.conf", int(vmid)
795
+
796
+
797
+ def _rewrite_pve_lxc_net(content: str, new: dict) -> str:
798
+ """Rewrite net0/hostname/nameserver/searchdomain in a PVE-LXC .conf.
799
+
800
+ Mirrors generate_pve_config: bridge -> net0: name=eth0,bridge=...,
801
+ (ip=/gw= when static),type=veth; host -> lxc.net.0.type: none; none ->
802
+ neither.
803
+ """
804
+ # Clear both possible network forms first.
805
+ content = _replace_conf_field(content, "net0", None)
806
+ content = _replace_conf_field(content, "lxc.net.0.type", None)
807
+
808
+ t = new["type"]
809
+ if t == "bridge" and new.get("bridge"):
810
+ ip_part = f",ip={new['ip']}" if new.get("ip") else ""
811
+ gw_part = (f",gw={new['gateway']}"
812
+ if new.get("ip") and new.get("gateway") else "")
813
+ content = _replace_conf_field(
814
+ content, "net0",
815
+ f"name=eth0,bridge={new['bridge']}{ip_part}{gw_part},type=veth")
816
+ elif t == "host":
817
+ content = _replace_conf_field(content, "lxc.net.0.type", "none")
818
+
819
+ # nameserver / searchdomain / hostname.
820
+ content = _replace_conf_field(
821
+ content, "nameserver", new.get("dns"))
822
+ content = _replace_conf_field(
823
+ content, "searchdomain", new.get("searchdomain"))
824
+ if new.get("hostname"):
825
+ content = _replace_conf_field(content, "hostname", new["hostname"])
826
+ return content
827
+
828
+
829
+ def _apply_pve_lxc(container_dir, memory, cores, pve_args,
830
+ net_delta=None) -> None:
831
+ from kento.pve import write_pve_config, _read_passthrough_lines
832
+
833
+ conf_path, vmid = _pve_lxc_conf_path(container_dir)
834
+ content = conf_path.read_text() if conf_path.is_file() else ""
835
+
836
+ # Metadata writes first (these feed the hook ns-cgroup at runtime).
837
+ if memory is not None:
838
+ _write_meta(container_dir, "memory", memory)
839
+ if cores is not None:
840
+ _write_meta(container_dir, "cores", cores)
841
+
842
+ # If pve_args is being changed, drop the OLD pass-through block before we
843
+ # overwrite the metadata file, then re-append the new block at the end.
844
+ pve_action = _classify_list(pve_args)
845
+ if pve_action != "skip":
846
+ old_block = _read_passthrough_lines(container_dir / "kento-pve-args")
847
+ content = _drop_passthrough_block(content, old_block)
848
+
849
+ # Re-emit kento-owned scalar lines, matching generate_pve_config:197-211.
850
+ if memory is not None:
851
+ content = _replace_conf_field(content, "memory", str(memory))
852
+ content = _replace_conf_field(
853
+ content, "lxc.cgroup2.memory.max", str(memory * 1048576))
854
+ if cores is not None:
855
+ content = _replace_conf_field(content, "cores", str(cores))
856
+ content = _replace_conf_field(content, "cpulimit", str(cores))
857
+ content = _replace_conf_field(
858
+ content, "lxc.cgroup2.cpu.max", f"{cores * 100000} 100000")
859
+
860
+ if net_delta is not None:
861
+ new = net_delta["new"]
862
+ content = _rewrite_pve_lxc_net(content, new)
863
+ _apply_port_meta(container_dir, net_delta["port"],
864
+ net_delta["port_action"])
865
+ _emit_guest_net_dropins(container_dir, new, "pve")
866
+ _persist_net_meta(container_dir, new)
867
+
868
+ if pve_action == "clear":
869
+ (container_dir / "kento-pve-args").unlink(missing_ok=True)
870
+ elif pve_action == "replace":
871
+ (container_dir / "kento-pve-args").write_text(
872
+ "\n".join(_nonempty(pve_args)) + "\n")
873
+ new_block = _read_passthrough_lines(container_dir / "kento-pve-args")
874
+ body = content.rstrip("\n")
875
+ content = body + "\n" + "\n".join(new_block) + "\n"
876
+
877
+ write_pve_config(vmid, content)
878
+
879
+
880
+ # ---------------------------------------------------------------------------
881
+ # PVE-VM: metadata + surgical rewrite of the qm .conf.
882
+ # ---------------------------------------------------------------------------
883
+
884
+ def _pve_vm_conf_path(container_dir: Path):
885
+ from kento.pve import PVE_DIR, _pve_node_name
886
+ vmid = int((container_dir / "kento-vmid").read_text().strip())
887
+ node = _pve_node_name()
888
+ return PVE_DIR / "nodes" / node / "qemu-server" / f"{vmid}.conf", vmid
889
+
890
+
891
+ def _apply_pve_vm(container_dir, memory, cores, mac, qemu_args,
892
+ pve_args, net_delta=None) -> None:
893
+ from kento.pve import (write_qm_config, sync_qm_args_to_memory,
894
+ generate_qm_args, _parse_qm_conf_field,
895
+ _read_passthrough_lines)
896
+
897
+ conf_path, vmid = _pve_vm_conf_path(container_dir)
898
+ content = conf_path.read_text() if conf_path.is_file() else ""
899
+
900
+ # Net delta (computed up front so the args: regen below picks up an updated
901
+ # kento-port). bridge -> rewrite net0 (preserve mac); usermode/none ->
902
+ # remove net0 and let generate_qm_args emit the slirp NIC from kento-port.
903
+ net_args_regen = False
904
+ if net_delta is not None:
905
+ new = net_delta["new"]
906
+ # Port metadata first — generate_qm_args reads kento-port for hostfwd.
907
+ _apply_port_meta(container_dir, net_delta["port"],
908
+ net_delta["port_action"])
909
+ if net_delta["port_action"] != "skip":
910
+ net_args_regen = True
911
+ if new["type"] == "bridge" and new.get("bridge"):
912
+ mac_val = (container_dir / "kento-mac").read_text().strip() \
913
+ if (container_dir / "kento-mac").is_file() else None
914
+ if mac is not None:
915
+ mac_val = mac
916
+ net0_val = (f"virtio={mac_val},bridge={new['bridge']}"
917
+ if mac_val else f"virtio,bridge={new['bridge']}")
918
+ content = _replace_conf_field(content, "net0", net0_val)
919
+ else:
920
+ # usermode / none: no qm net0; slirp NIC (if any) rides args:.
921
+ content = _replace_conf_field(content, "net0", None)
922
+ # The slirp NIC is usermode-only for VM modes: generate_qm_args emits it
923
+ # purely on kento-port's presence (mirroring create, which writes
924
+ # kento-port only for usermode). A switch AWAY from usermode must drop
925
+ # kento-port, else the regenerated args: would keep a stale slirp NIC —
926
+ # alongside a bridge net0 (two NICs fighting over id=net0 -> broken
927
+ # boot) or on a none-network instance.
928
+ if new["type"] != "usermode":
929
+ (container_dir / "kento-port").unlink(missing_ok=True)
930
+ # Any network type/bridge change requires args: reconsideration (the
931
+ # slirp NIC appears/disappears with the resolved type).
932
+ net_args_regen = True
933
+ # Guest-side: static ip (bridge), dns, hostname via overlay drop-ins.
934
+ _emit_guest_net_dropins(container_dir, new, "pve-vm")
935
+ _persist_net_meta(container_dir, new)
936
+
937
+ # memory: patch memory: line, write metadata, then resync args: memfd
938
+ # size via the existing helper (which reads the conf back from disk).
939
+ if memory is not None:
940
+ _write_meta(container_dir, "memory", memory)
941
+ content = _replace_conf_field(content, "memory", str(memory))
942
+
943
+ if cores is not None:
944
+ _write_meta(container_dir, "cores", cores)
945
+ content = _replace_conf_field(content, "cores", str(cores))
946
+
947
+ if mac is not None:
948
+ _write_meta(container_dir, "mac", mac)
949
+ net0 = _parse_qm_conf_field(content, "net0")
950
+ if net0 is not None:
951
+ # Existing format: virtio=<MAC>,bridge=<name>. Swap only the MAC,
952
+ # preserving the bridge (and any other) parts.
953
+ parts = net0.split(",")
954
+ new_parts = []
955
+ matched = False
956
+ for part in parts:
957
+ if part.startswith("virtio="):
958
+ new_parts.append(f"virtio={mac}")
959
+ matched = True
960
+ elif part == "virtio":
961
+ new_parts.append(f"virtio={mac}")
962
+ matched = True
963
+ else:
964
+ new_parts.append(part)
965
+ content = _replace_conf_field(content, "net0", ",".join(new_parts))
966
+ if not matched:
967
+ # net0 exists but has no virtio token (e.g. a user-edited
968
+ # non-virtio model). The MAC couldn't be applied to the conf;
969
+ # warn so "Updated" isn't misleading (metadata still written).
970
+ logger.warning(
971
+ "net0 has no 'virtio' token; the MAC %r "
972
+ "could not be applied to the existing net0 form. "
973
+ "kento-mac metadata was updated but the qm config NIC "
974
+ "was left unchanged.", mac
975
+ )
976
+ # If net0 absent, best-effort: metadata written, skip conf edit.
977
+
978
+ # pve_args: drop old block, re-emit new at end (before persisting we read
979
+ # the old block from the still-current metadata file).
980
+ pve_action = _classify_list(pve_args)
981
+ if pve_action != "skip":
982
+ old_block = _read_passthrough_lines(container_dir / "kento-pve-args")
983
+ content = _drop_passthrough_block(content, old_block)
984
+
985
+ # qemu_args: write/clear metadata, then regenerate the args: line. Use the
986
+ # memory currently in the conf (post-patch) so memfd size stays correct.
987
+ qemu_action = _classify_list(qemu_args)
988
+ if qemu_action == "clear":
989
+ (container_dir / "kento-qemu-args").unlink(missing_ok=True)
990
+ elif qemu_action == "replace":
991
+ (container_dir / "kento-qemu-args").write_text(
992
+ "\n".join(_nonempty(qemu_args)) + "\n")
993
+
994
+ # Write the conf now so sync/generate helpers (which read from disk) see
995
+ # the patched memory:/cores:/net0:.
996
+ write_qm_config(vmid, content)
997
+
998
+ if memory is not None:
999
+ # Rewrite args: memfd size= to match the new memory: + re-sync
1000
+ # kento-memory/kento-cores from the conf (PVE wins). This already
1001
+ # regenerates the full args: line (incl. the slirp hostfwd from
1002
+ # kento-port), so a concurrent net/port change is reflected too.
1003
+ sync_qm_args_to_memory(vmid, container_dir)
1004
+ content = conf_path.read_text()
1005
+ elif qemu_action != "skip" or net_args_regen:
1006
+ # Regenerate the args: line so the pass-through block reflects the new
1007
+ # kento-qemu-args. Use current memory from the conf (or kento-memory).
1008
+ mem_raw = _parse_qm_conf_field(content, "memory")
1009
+ try:
1010
+ mem = int(mem_raw) if mem_raw is not None else None
1011
+ except ValueError:
1012
+ mem = None
1013
+ if mem is None:
1014
+ mem_file = container_dir / "kento-memory"
1015
+ mem = (int(mem_file.read_text().strip())
1016
+ if mem_file.is_file() else 512)
1017
+ new_args = f"args: {generate_qm_args(container_dir, memory=mem)}"
1018
+ content = _replace_conf_field(content, "args", None)
1019
+ # _replace_conf_field with None removed the old args; append fresh.
1020
+ body = content.rstrip("\n")
1021
+ # Re-insert args in the global section (append; qm tolerates ordering).
1022
+ content = body + "\n" + new_args + "\n"
1023
+ write_qm_config(vmid, content)
1024
+ content = conf_path.read_text()
1025
+
1026
+ # Re-emit the pve_args pass-through block at the end of the conf.
1027
+ if pve_action == "clear":
1028
+ (container_dir / "kento-pve-args").unlink(missing_ok=True)
1029
+ write_qm_config(vmid, content)
1030
+ elif pve_action == "replace":
1031
+ (container_dir / "kento-pve-args").write_text(
1032
+ "\n".join(_nonempty(pve_args)) + "\n")
1033
+ new_block = _read_passthrough_lines(container_dir / "kento-pve-args")
1034
+ body = content.rstrip("\n")
1035
+ content = body + "\n" + "\n".join(new_block) + "\n"
1036
+ write_qm_config(vmid, content)