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