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/__init__.py
ADDED
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
"""Kento — compose OCI images into LXC system containers via overlayfs."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
import os
|
|
5
|
+
import pwd
|
|
6
|
+
import re
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
try:
|
|
10
|
+
from importlib.metadata import version as _pkg_version
|
|
11
|
+
__version__ = _pkg_version("kento-core")
|
|
12
|
+
except Exception:
|
|
13
|
+
__version__ = "unknown"
|
|
14
|
+
|
|
15
|
+
logging.getLogger("kento").addHandler(logging.NullHandler())
|
|
16
|
+
logger = logging.getLogger("kento")
|
|
17
|
+
|
|
18
|
+
from kento.errors import ( # noqa: F401 (public re-export)
|
|
19
|
+
KentoError, ValidationError, InstanceNotFoundError, InstanceExistsError,
|
|
20
|
+
ImageNotFoundError, ModeError, StateError, SubprocessError,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
LXC_BASE = Path("/var/lib/lxc")
|
|
24
|
+
VM_BASE = Path("/var/lib/kento/vm")
|
|
25
|
+
|
|
26
|
+
_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]*$")
|
|
27
|
+
_NAME_MAX_LEN = 63
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def validate_name(name: str, *, what: str = "instance name") -> None:
|
|
31
|
+
"""Reject names that would enable injection or path traversal.
|
|
32
|
+
|
|
33
|
+
Accepts: ASCII alphanumerics plus `_`, `.`, `-`. Must start with
|
|
34
|
+
alphanumeric. Max 63 chars (matches Linux HOST_NAME_MAX constraint).
|
|
35
|
+
Rejects: empty, whitespace, shell metacharacters, `/`, `..`, NUL.
|
|
36
|
+
|
|
37
|
+
Raises ValidationError on rejection. what is used in the message for
|
|
38
|
+
context (e.g. "instance name", "auto-generated name").
|
|
39
|
+
"""
|
|
40
|
+
if not isinstance(name, str) or not name:
|
|
41
|
+
raise ValidationError(f"{what} cannot be empty")
|
|
42
|
+
if len(name) > _NAME_MAX_LEN:
|
|
43
|
+
raise ValidationError(
|
|
44
|
+
f"{what} too long ({len(name)} chars, max {_NAME_MAX_LEN}): {name!r}"
|
|
45
|
+
)
|
|
46
|
+
if "\x00" in name:
|
|
47
|
+
raise ValidationError(f"{what} contains NUL byte: {name!r}")
|
|
48
|
+
if not _NAME_RE.match(name):
|
|
49
|
+
raise ValidationError(
|
|
50
|
+
f"invalid {what}: {name!r}. Names must start with a letter "
|
|
51
|
+
f"or digit and contain only [A-Za-z0-9_.-] (max {_NAME_MAX_LEN} chars)."
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _bridge_exists(name: str) -> bool:
|
|
56
|
+
"""Check if a network bridge interface exists."""
|
|
57
|
+
return Path(f"/sys/class/net/{name}").is_dir()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def detect_bridge() -> str | None:
|
|
61
|
+
"""Detect the first available network bridge.
|
|
62
|
+
|
|
63
|
+
Checks vmbr0 (PVE default), then lxcbr0 (LXC default).
|
|
64
|
+
Returns the bridge name or None if no bridge found.
|
|
65
|
+
"""
|
|
66
|
+
for name in ("vmbr0", "lxcbr0"):
|
|
67
|
+
if _bridge_exists(name):
|
|
68
|
+
return name
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def resolve_network(net_type: str | None, bridge_name: str | None,
|
|
73
|
+
mode: str, port: str | None = None) -> dict:
|
|
74
|
+
"""Resolve network configuration for container/VM creation.
|
|
75
|
+
|
|
76
|
+
Returns dict with keys: type, bridge, port
|
|
77
|
+
- type: "bridge", "host", "usermode", or "none"
|
|
78
|
+
- bridge: bridge name (str) or None
|
|
79
|
+
- port: "host:guest" (str) or None
|
|
80
|
+
"""
|
|
81
|
+
# Port implies usermode if no explicit network set (VM/PVE-VM only).
|
|
82
|
+
# For LXC/PVE, port forwarding uses iptables DNAT which requires bridge.
|
|
83
|
+
if port is not None and net_type is None:
|
|
84
|
+
if mode in ("vm", "pve-vm"):
|
|
85
|
+
net_type = "usermode"
|
|
86
|
+
|
|
87
|
+
# Auto-detect if no network type specified
|
|
88
|
+
if net_type is None:
|
|
89
|
+
if mode == "vm":
|
|
90
|
+
# Plain VM has no bridge support in start_vm (QEMU would need a tap
|
|
91
|
+
# device). Auto-detecting bridge here silently produces a VM with no
|
|
92
|
+
# network at all. Default to usermode instead; user can still pass
|
|
93
|
+
# --network bridge=<name> explicitly (pve-vm handles bridge via qm).
|
|
94
|
+
net_type = "usermode"
|
|
95
|
+
logger.info("Network: using usermode networking (plain VM default)")
|
|
96
|
+
else:
|
|
97
|
+
bridge = detect_bridge()
|
|
98
|
+
if bridge:
|
|
99
|
+
net_type = "bridge"
|
|
100
|
+
bridge_name = bridge
|
|
101
|
+
logger.info("Network: using bridge %s", bridge)
|
|
102
|
+
elif mode == "pve-vm":
|
|
103
|
+
net_type = "usermode"
|
|
104
|
+
logger.info("Network: no bridge found, using usermode networking")
|
|
105
|
+
else:
|
|
106
|
+
net_type = "none"
|
|
107
|
+
logger.info("Network: no bridge found, networking disabled")
|
|
108
|
+
elif net_type == "bridge" and bridge_name is None:
|
|
109
|
+
# --network bridge without name: auto-detect bridge
|
|
110
|
+
bridge_name = detect_bridge()
|
|
111
|
+
if bridge_name is None:
|
|
112
|
+
raise ValidationError(
|
|
113
|
+
"--network bridge specified but no bridge interface found "
|
|
114
|
+
"(checked vmbr0, lxcbr0)"
|
|
115
|
+
)
|
|
116
|
+
logger.info("Network: using bridge %s", bridge_name)
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
"type": net_type,
|
|
120
|
+
"bridge": bridge_name,
|
|
121
|
+
"port": port,
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def read_mode(container_dir: Path, default: str = "lxc") -> str:
|
|
126
|
+
"""Read the kento-mode file from a container directory."""
|
|
127
|
+
mode_file = container_dir / "kento-mode"
|
|
128
|
+
return mode_file.read_text().strip() if mode_file.is_file() else default
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def require_root() -> None:
|
|
132
|
+
if os.getuid() != 0:
|
|
133
|
+
raise StateError("must run as root. Re-run with sudo (e.g. 'sudo kento ...').")
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def detect_mode(force: str | None = None) -> str:
|
|
137
|
+
"""Return 'pve', 'lxc', or 'vm' based on environment or explicit override.
|
|
138
|
+
|
|
139
|
+
When force is set (e.g. 'vm'), returns it directly.
|
|
140
|
+
Otherwise auto-detects PVE vs plain LXC (VM is never auto-detected).
|
|
141
|
+
"""
|
|
142
|
+
if force:
|
|
143
|
+
return force
|
|
144
|
+
from kento.pve import is_pve
|
|
145
|
+
return "pve" if is_pve() else "lxc"
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def upper_base(name: str, base: Path | None = None) -> Path:
|
|
149
|
+
"""Return the base directory for a container's upper and work dirs.
|
|
150
|
+
|
|
151
|
+
Resolution order:
|
|
152
|
+
1. If ``KENTO_STATE_DIR`` is set and non-empty, use it as the base
|
|
153
|
+
(``~`` is expanded). Takes precedence over sudo/root detection.
|
|
154
|
+
Useful when the default location sits on an overlayfs (e.g.
|
|
155
|
+
nested-LXC rootfs), which the kernel refuses as an upperdir.
|
|
156
|
+
2. When run via sudo, uses the invoking user's XDG data directory
|
|
157
|
+
(~user/.local/share/kento/<name>/) so writable state is per-user.
|
|
158
|
+
3. When run as root directly, uses the provided base (or LXC_BASE)/<name>/.
|
|
159
|
+
"""
|
|
160
|
+
override = os.environ.get("KENTO_STATE_DIR")
|
|
161
|
+
if override:
|
|
162
|
+
if override.startswith("~"):
|
|
163
|
+
override = os.path.expanduser(override)
|
|
164
|
+
return Path(override) / name
|
|
165
|
+
sudo_user = os.environ.get("SUDO_USER")
|
|
166
|
+
if sudo_user:
|
|
167
|
+
try:
|
|
168
|
+
home = Path(pwd.getpwnam(sudo_user).pw_dir)
|
|
169
|
+
except KeyError:
|
|
170
|
+
raise StateError(
|
|
171
|
+
f"SUDO_USER={sudo_user!r} is not a known user; "
|
|
172
|
+
f"set KENTO_STATE_DIR or run directly as root."
|
|
173
|
+
)
|
|
174
|
+
return home / ".local" / "share" / "kento" / name
|
|
175
|
+
return (base or LXC_BASE) / name
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def sanitize_image_name(image: str) -> str:
|
|
179
|
+
"""Convert an OCI image reference to a filesystem-safe name.
|
|
180
|
+
|
|
181
|
+
Substitution order: '-' → '--', '/' → '-', '_' → '__', ':' → '_'
|
|
182
|
+
|
|
183
|
+
The transformation is injective for typical OCI image references but not
|
|
184
|
+
bijective in the general case — adjacent '_:' and ':_' sequences produce
|
|
185
|
+
collisions (e.g. 'a_:b' and 'a:_b' both map to 'a___b').
|
|
186
|
+
"""
|
|
187
|
+
s = image.replace("-", "--")
|
|
188
|
+
s = s.replace("/", "-")
|
|
189
|
+
s = s.replace("_", "__")
|
|
190
|
+
s = s.replace(":", "_")
|
|
191
|
+
return s
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def next_instance_name(base_name: str, scan_dir: Path,
|
|
195
|
+
other_dir: Path | None = None) -> str:
|
|
196
|
+
"""Return the next available auto-generated instance name.
|
|
197
|
+
|
|
198
|
+
Appends -0, -1, -2, ... to base_name until an unused name is found.
|
|
199
|
+
Checks both directory names and kento-name files in scan_dir.
|
|
200
|
+
When other_dir is provided, also checks that directory for name conflicts
|
|
201
|
+
so that auto-generated names are unique across both namespaces.
|
|
202
|
+
"""
|
|
203
|
+
used_names: set[str] = set()
|
|
204
|
+
for d_root in (scan_dir, other_dir):
|
|
205
|
+
if d_root is not None and d_root.is_dir():
|
|
206
|
+
for d in d_root.iterdir():
|
|
207
|
+
if d.is_dir():
|
|
208
|
+
used_names.add(d.name)
|
|
209
|
+
name_file = d / "kento-name"
|
|
210
|
+
if name_file.is_file():
|
|
211
|
+
used_names.add(name_file.read_text().strip())
|
|
212
|
+
n = 0
|
|
213
|
+
while True:
|
|
214
|
+
candidate = f"{base_name}-{n}"
|
|
215
|
+
if candidate not in used_names:
|
|
216
|
+
return candidate
|
|
217
|
+
n += 1
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def pve_config_exists(vmid: str, mode: str) -> bool:
|
|
221
|
+
"""Return whether the PVE config file for vmid/mode exists on this node.
|
|
222
|
+
|
|
223
|
+
A missing config means the instance is GONE (destroyed/lost out-of-band),
|
|
224
|
+
leaving kento's state dir orphaned. Callers use this to distinguish that
|
|
225
|
+
case from a transient status-query failure.
|
|
226
|
+
|
|
227
|
+
Path construction mirrors delete_qm_config / delete_pve_config in pve.py:
|
|
228
|
+
- pve-vm: PVE_DIR/nodes/<node>/qemu-server/<vmid>.conf
|
|
229
|
+
- pve: PVE_DIR/nodes/<node>/lxc/<vmid>.conf
|
|
230
|
+
|
|
231
|
+
Defensive: if the node name can't be resolved (no /etc/pve/local and no
|
|
232
|
+
hostname), fall back to True so callers keep their existing behavior
|
|
233
|
+
rather than crashing or wrongly declaring an instance gone.
|
|
234
|
+
"""
|
|
235
|
+
from kento.pve import PVE_DIR, _pve_node_name
|
|
236
|
+
try:
|
|
237
|
+
node = _pve_node_name()
|
|
238
|
+
except Exception:
|
|
239
|
+
return True
|
|
240
|
+
subdir = "qemu-server" if mode == "pve-vm" else "lxc"
|
|
241
|
+
conf_path = PVE_DIR / "nodes" / node / subdir / f"{vmid}.conf"
|
|
242
|
+
return conf_path.is_file()
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def is_running(container_dir: Path, mode: str) -> bool:
|
|
246
|
+
"""Check if a container is running, using the mode-appropriate method.
|
|
247
|
+
|
|
248
|
+
For PVE modes (pve, pve-vm) we wrap the status query with a 5-second
|
|
249
|
+
timeout. An unreachable PVE node or hung pmxcfs would otherwise make
|
|
250
|
+
`kento stop` hang indefinitely. On timeout or non-zero rc we ASSUME
|
|
251
|
+
RUNNING (return True) — skipping a stop on a still-running instance
|
|
252
|
+
leaks state, so the conservative choice is to attempt the stop.
|
|
253
|
+
|
|
254
|
+
The cost of that conservatism is that a stop may then be issued on an
|
|
255
|
+
instance that is in fact already stopped (the status query merely
|
|
256
|
+
failed). stop.py's PVE/pve-vm shutdown path tolerates this: it issues
|
|
257
|
+
the pct/qm shutdown non-fatally and treats a "not running" result as
|
|
258
|
+
"Already stopped" rather than hard-exiting. (A missing PVE config is
|
|
259
|
+
handled separately above as not-running, since that means the instance
|
|
260
|
+
is gone, not merely unreachable.)
|
|
261
|
+
"""
|
|
262
|
+
import subprocess
|
|
263
|
+
if mode == "vm":
|
|
264
|
+
from kento.vm import is_vm_running
|
|
265
|
+
return is_vm_running(container_dir)
|
|
266
|
+
elif mode == "pve-vm":
|
|
267
|
+
vmid_file = container_dir / "kento-vmid"
|
|
268
|
+
if not vmid_file.is_file():
|
|
269
|
+
return False
|
|
270
|
+
vmid = vmid_file.read_text().strip()
|
|
271
|
+
# A missing PVE config means the instance is GONE (destroyed
|
|
272
|
+
# out-of-band), leaving our state dir orphaned. Treat as not-running
|
|
273
|
+
# so `stop` no-ops and `destroy -f` skips the stop. Only the
|
|
274
|
+
# config-PRESENT, status-failed case is a transient "assume running".
|
|
275
|
+
if not pve_config_exists(vmid, "pve-vm"):
|
|
276
|
+
return False
|
|
277
|
+
try:
|
|
278
|
+
result = subprocess.run(
|
|
279
|
+
["qm", "status", vmid],
|
|
280
|
+
capture_output=True, text=True, timeout=5,
|
|
281
|
+
)
|
|
282
|
+
except subprocess.TimeoutExpired:
|
|
283
|
+
logger.warning(
|
|
284
|
+
"qm status timed out; assuming instance may be running"
|
|
285
|
+
)
|
|
286
|
+
return True
|
|
287
|
+
if result.returncode != 0:
|
|
288
|
+
logger.warning(
|
|
289
|
+
"qm status returned non-zero; assuming instance may be running"
|
|
290
|
+
)
|
|
291
|
+
return True
|
|
292
|
+
return "running" in result.stdout
|
|
293
|
+
elif mode == "pve":
|
|
294
|
+
# Missing PVE config => instance gone (see pve-vm branch above).
|
|
295
|
+
if not pve_config_exists(container_dir.name, "pve"):
|
|
296
|
+
return False
|
|
297
|
+
try:
|
|
298
|
+
result = subprocess.run(
|
|
299
|
+
["pct", "status", container_dir.name],
|
|
300
|
+
capture_output=True, text=True, timeout=5,
|
|
301
|
+
)
|
|
302
|
+
except subprocess.TimeoutExpired:
|
|
303
|
+
logger.warning(
|
|
304
|
+
"pct status timed out; assuming instance may be running"
|
|
305
|
+
)
|
|
306
|
+
return True
|
|
307
|
+
if result.returncode != 0:
|
|
308
|
+
logger.warning(
|
|
309
|
+
"pct status returned non-zero; assuming instance may be running"
|
|
310
|
+
)
|
|
311
|
+
return True
|
|
312
|
+
return "running" in result.stdout
|
|
313
|
+
else:
|
|
314
|
+
result = subprocess.run(
|
|
315
|
+
["lxc-info", "-n", container_dir.name, "-sH"],
|
|
316
|
+
capture_output=True, text=True,
|
|
317
|
+
)
|
|
318
|
+
return result.returncode == 0 and "RUNNING" in result.stdout
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def resolve_container(name: str, scan_dir: Path | None = None) -> Path:
|
|
322
|
+
"""Resolve a container name to its directory path.
|
|
323
|
+
|
|
324
|
+
For LXC mode, the name IS the directory name (fast path).
|
|
325
|
+
For PVE mode, scans kento-name files to find the matching directory.
|
|
326
|
+
When scan_dir is None, searches both LXC_BASE and VM_BASE.
|
|
327
|
+
Returns the container directory path, or exits with error if not found.
|
|
328
|
+
"""
|
|
329
|
+
validate_name(name)
|
|
330
|
+
bases = [scan_dir] if scan_dir else [LXC_BASE, VM_BASE]
|
|
331
|
+
|
|
332
|
+
for base in bases:
|
|
333
|
+
# Fast path: directory name matches
|
|
334
|
+
direct = base / name
|
|
335
|
+
if direct.is_dir() and (direct / "kento-image").is_file():
|
|
336
|
+
return direct
|
|
337
|
+
|
|
338
|
+
# Scan kento-name files
|
|
339
|
+
if base.is_dir():
|
|
340
|
+
for d in sorted(base.iterdir()):
|
|
341
|
+
if not d.is_dir():
|
|
342
|
+
continue
|
|
343
|
+
name_file = d / "kento-name"
|
|
344
|
+
if name_file.is_file() and name_file.read_text().strip() == name:
|
|
345
|
+
if (d / "kento-image").is_file():
|
|
346
|
+
return d
|
|
347
|
+
|
|
348
|
+
raise InstanceNotFoundError(
|
|
349
|
+
f"no instance named '{name}'. "
|
|
350
|
+
f"Run 'kento list' to see available instances."
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _scan_namespace(name: str, base: Path) -> Path | None:
|
|
355
|
+
"""Scan a single base directory for a container/VM by name.
|
|
356
|
+
|
|
357
|
+
Returns the directory path if found, None otherwise.
|
|
358
|
+
"""
|
|
359
|
+
# Fast path: directory name matches
|
|
360
|
+
direct = base / name
|
|
361
|
+
if direct.is_dir() and (direct / "kento-image").is_file():
|
|
362
|
+
return direct
|
|
363
|
+
|
|
364
|
+
# Scan kento-name files
|
|
365
|
+
if base.is_dir():
|
|
366
|
+
for d in sorted(base.iterdir()):
|
|
367
|
+
if not d.is_dir():
|
|
368
|
+
continue
|
|
369
|
+
name_file = d / "kento-name"
|
|
370
|
+
if name_file.is_file() and name_file.read_text().strip() == name:
|
|
371
|
+
if (d / "kento-image").is_file():
|
|
372
|
+
return d
|
|
373
|
+
return None
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def resolve_in_namespace(name: str, namespace: str) -> Path:
|
|
377
|
+
"""Resolve a name within a specific namespace ('lxc'/'container' or 'vm').
|
|
378
|
+
|
|
379
|
+
Searches only LXC_BASE (for 'lxc'/'container') or VM_BASE (for 'vm').
|
|
380
|
+
Exits with error if not found.
|
|
381
|
+
"""
|
|
382
|
+
validate_name(name)
|
|
383
|
+
base = LXC_BASE if namespace in ("container", "lxc") else VM_BASE
|
|
384
|
+
result = _scan_namespace(name, base)
|
|
385
|
+
if result is not None:
|
|
386
|
+
return result
|
|
387
|
+
list_cmd = "kento vm list" if namespace == "vm" else "kento lxc list"
|
|
388
|
+
raise InstanceNotFoundError(
|
|
389
|
+
f"no {namespace} named '{name}'. "
|
|
390
|
+
f"Run '{list_cmd}' to see available instances."
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def resolve_any(name: str, namespace: str | None = None) -> tuple[Path, str]:
|
|
395
|
+
"""Resolve a name, optionally constrained to a single namespace.
|
|
396
|
+
|
|
397
|
+
Returns (container_dir, mode) where mode is read from the kento-mode file.
|
|
398
|
+
|
|
399
|
+
When ``namespace`` is 'lxc'/'container' or 'vm', the search is confined to
|
|
400
|
+
that namespace's base directory (mirroring resolve_in_namespace): there is
|
|
401
|
+
no cross-namespace ambiguity check, and a miss exits with a branded
|
|
402
|
+
"instance not found" error. This is how callers honor an explicit
|
|
403
|
+
``kento lxc <cmd>`` / ``kento vm <cmd>`` scope so duplicate names created
|
|
404
|
+
via ``create --force`` can be disambiguated.
|
|
405
|
+
|
|
406
|
+
When ``namespace`` is None (the default — unchanged from prior behavior),
|
|
407
|
+
both namespaces are searched and an ambiguous name (present in both) exits
|
|
408
|
+
with an error directing the user to pick a scope.
|
|
409
|
+
"""
|
|
410
|
+
validate_name(name)
|
|
411
|
+
|
|
412
|
+
if namespace in ("container", "lxc"):
|
|
413
|
+
hit = _scan_namespace(name, LXC_BASE)
|
|
414
|
+
if hit is not None:
|
|
415
|
+
return hit, read_mode(hit)
|
|
416
|
+
raise InstanceNotFoundError(
|
|
417
|
+
f"no lxc named '{name}'. "
|
|
418
|
+
f"Run 'kento lxc list' to see available instances."
|
|
419
|
+
)
|
|
420
|
+
if namespace == "vm":
|
|
421
|
+
hit = _scan_namespace(name, VM_BASE)
|
|
422
|
+
if hit is not None:
|
|
423
|
+
return hit, read_mode(hit, "vm")
|
|
424
|
+
raise InstanceNotFoundError(
|
|
425
|
+
f"no vm named '{name}'. "
|
|
426
|
+
f"Run 'kento vm list' to see available instances."
|
|
427
|
+
)
|
|
428
|
+
|
|
429
|
+
lxc_hit = _scan_namespace(name, LXC_BASE)
|
|
430
|
+
vm_hit = _scan_namespace(name, VM_BASE)
|
|
431
|
+
|
|
432
|
+
if lxc_hit and vm_hit:
|
|
433
|
+
raise KentoError(
|
|
434
|
+
f"ambiguous name '{name}' — exists as both LXC and VM "
|
|
435
|
+
f"instance. Use 'kento lxc <cmd>' or 'kento vm <cmd>'."
|
|
436
|
+
)
|
|
437
|
+
|
|
438
|
+
if lxc_hit:
|
|
439
|
+
return lxc_hit, read_mode(lxc_hit)
|
|
440
|
+
|
|
441
|
+
if vm_hit:
|
|
442
|
+
return vm_hit, read_mode(vm_hit, "vm")
|
|
443
|
+
|
|
444
|
+
raise InstanceNotFoundError(
|
|
445
|
+
f"no instance named '{name}'. "
|
|
446
|
+
f"Run 'kento list' to see available instances."
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def check_name_conflict(name: str, target_namespace: str) -> bool:
|
|
451
|
+
"""Check if a name already exists in the OTHER namespace.
|
|
452
|
+
|
|
453
|
+
Returns True if a conflict exists, False otherwise.
|
|
454
|
+
Does not error — the caller decides what to do.
|
|
455
|
+
"""
|
|
456
|
+
validate_name(name)
|
|
457
|
+
if target_namespace in ("container", "lxc"):
|
|
458
|
+
other_base = VM_BASE
|
|
459
|
+
else:
|
|
460
|
+
other_base = LXC_BASE
|
|
461
|
+
return _scan_namespace(name, other_base) is not None
|
kento/attach.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""Attach to a kento-managed instance's console (interactive).
|
|
2
|
+
|
|
3
|
+
Dispatch per mode:
|
|
4
|
+
- lxc -> lxc-attach -n <name> (inherited stdio)
|
|
5
|
+
- pve -> pct enter <vmid> (pve-lxc; vmid is the instance dir name)
|
|
6
|
+
- pve-vm -> qm terminal <vmid>
|
|
7
|
+
- vm -> pure-Python serial relay to <container_dir>/serial.sock
|
|
8
|
+
|
|
9
|
+
The VM path connects an AF_UNIX socket to the serial console exposed by
|
|
10
|
+
start_vm (D1) and relays stdin<->socket with the local tty in raw mode.
|
|
11
|
+
Detach with Ctrl-] then Q. The escape handling lives in EscapeDetector, a
|
|
12
|
+
pure state machine that is unit-testable without a tty or socket.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
import os
|
|
17
|
+
import select
|
|
18
|
+
import socket
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from kento import read_mode, require_root, resolve_any
|
|
24
|
+
from kento.errors import StateError
|
|
25
|
+
|
|
26
|
+
logger = logging.getLogger("kento")
|
|
27
|
+
|
|
28
|
+
# Ctrl-] (GS, group separator) — the classic telnet/qm escape lead-in.
|
|
29
|
+
ESCAPE_BYTE = 0x1d
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _write_all(fd: int, data: bytes) -> None:
|
|
33
|
+
"""Write all of ``data`` to ``fd``, looping past short os.write() returns.
|
|
34
|
+
|
|
35
|
+
os.write() may write fewer bytes than requested (stdout can be a pipe or
|
|
36
|
+
file, e.g. `kento attach <vm> | tee log`); a single write would silently
|
|
37
|
+
drop the remainder. The fd is blocking, so we just loop until drained.
|
|
38
|
+
"""
|
|
39
|
+
mv = memoryview(data)
|
|
40
|
+
while mv:
|
|
41
|
+
n = os.write(fd, mv)
|
|
42
|
+
mv = mv[n:]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class EscapeDetector:
|
|
46
|
+
"""Pure state machine translating raw stdin bytes into relay actions.
|
|
47
|
+
|
|
48
|
+
Feed one byte at a time. Each ``feed`` returns one of:
|
|
49
|
+
- ("forward", bytes) send these bytes to the socket
|
|
50
|
+
- ("detach", None) user requested detach (Ctrl-] then Q/q)
|
|
51
|
+
- ("swallow", None) byte consumed, nothing to send yet
|
|
52
|
+
|
|
53
|
+
Sequence semantics:
|
|
54
|
+
- A lone ESCAPE_BYTE (0x1d) is swallowed and arms the detector.
|
|
55
|
+
- While armed, the next byte decides:
|
|
56
|
+
* 'Q'/'q' -> detach
|
|
57
|
+
* ESCAPE_BYTE -> forward a single literal 0x1d (so a doubled
|
|
58
|
+
Ctrl-] sends one Ctrl-] through; the detector
|
|
59
|
+
disarms, NOT re-arms)
|
|
60
|
+
* anything else -> forward the held 0x1d followed by that byte
|
|
61
|
+
(the escape was not completed, so the lead-in
|
|
62
|
+
is delivered verbatim)
|
|
63
|
+
- The detector disarms after resolving an armed byte.
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
def __init__(self) -> None:
|
|
67
|
+
self._armed = False
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def armed(self) -> bool:
|
|
71
|
+
return self._armed
|
|
72
|
+
|
|
73
|
+
def feed(self, byte: int) -> tuple[str, bytes | None]:
|
|
74
|
+
if not self._armed:
|
|
75
|
+
if byte == ESCAPE_BYTE:
|
|
76
|
+
self._armed = True
|
|
77
|
+
return ("swallow", None)
|
|
78
|
+
return ("forward", bytes([byte]))
|
|
79
|
+
|
|
80
|
+
# Armed: resolve the second byte of a potential escape sequence.
|
|
81
|
+
self._armed = False
|
|
82
|
+
if byte in (ord("Q"), ord("q")):
|
|
83
|
+
return ("detach", None)
|
|
84
|
+
if byte == ESCAPE_BYTE:
|
|
85
|
+
# Doubled Ctrl-]: send one literal through, stay disarmed.
|
|
86
|
+
return ("forward", bytes([ESCAPE_BYTE]))
|
|
87
|
+
# Not an escape: deliver the swallowed lead-in then this byte.
|
|
88
|
+
return ("forward", bytes([ESCAPE_BYTE, byte]))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _relay_serial(name: str, container_dir: Path) -> int:
|
|
92
|
+
"""Interactive serial console relay for VM mode. Returns an exit code."""
|
|
93
|
+
sock_path = container_dir / "serial.sock"
|
|
94
|
+
if not sock_path.exists():
|
|
95
|
+
raise StateError(
|
|
96
|
+
f"serial socket not found for '{name}' "
|
|
97
|
+
f"({sock_path}). The instance is not running, or it was started "
|
|
98
|
+
f"by an older kento without serial support. Start it with "
|
|
99
|
+
f"'kento start {name}' and retry."
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
stdin_fd = sys.stdin.fileno()
|
|
104
|
+
is_tty = os.isatty(stdin_fd)
|
|
105
|
+
except (OSError, ValueError, AttributeError):
|
|
106
|
+
# Redirected/replaced stdin with no real fd: not interactive.
|
|
107
|
+
is_tty = False
|
|
108
|
+
if not is_tty:
|
|
109
|
+
raise StateError(
|
|
110
|
+
"'kento attach' on a VM needs an interactive terminal "
|
|
111
|
+
"(stdin is not a tty). Run it from a real terminal, or use SSH "
|
|
112
|
+
"for non-interactive access."
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
try:
|
|
116
|
+
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
117
|
+
sock.connect(str(sock_path))
|
|
118
|
+
except OSError as exc:
|
|
119
|
+
raise StateError(
|
|
120
|
+
f"could not connect to serial socket {sock_path}: {exc}. "
|
|
121
|
+
f"Is '{name}' running?"
|
|
122
|
+
) from exc
|
|
123
|
+
|
|
124
|
+
logger.info("Connected to %s. Escape: Ctrl-] then Q", name)
|
|
125
|
+
|
|
126
|
+
import termios
|
|
127
|
+
import tty
|
|
128
|
+
|
|
129
|
+
old_attrs = termios.tcgetattr(stdin_fd)
|
|
130
|
+
detector = EscapeDetector()
|
|
131
|
+
sock_fd = sock.fileno()
|
|
132
|
+
try:
|
|
133
|
+
tty.setraw(stdin_fd)
|
|
134
|
+
while True:
|
|
135
|
+
rlist, _, _ = select.select([stdin_fd, sock_fd], [], [])
|
|
136
|
+
if sock_fd in rlist:
|
|
137
|
+
data = sock.recv(65536)
|
|
138
|
+
if not data:
|
|
139
|
+
break # socket EOF: VM/console closed
|
|
140
|
+
_write_all(sys.stdout.fileno(), data)
|
|
141
|
+
if stdin_fd in rlist:
|
|
142
|
+
data = os.read(stdin_fd, 65536)
|
|
143
|
+
if not data:
|
|
144
|
+
break # stdin EOF
|
|
145
|
+
detached = False
|
|
146
|
+
out = bytearray()
|
|
147
|
+
for b in data:
|
|
148
|
+
action, payload = detector.feed(b)
|
|
149
|
+
if action == "detach":
|
|
150
|
+
detached = True
|
|
151
|
+
break
|
|
152
|
+
if action == "forward" and payload:
|
|
153
|
+
out.extend(payload)
|
|
154
|
+
# "swallow": nothing to forward
|
|
155
|
+
if out:
|
|
156
|
+
sock.sendall(bytes(out))
|
|
157
|
+
if detached:
|
|
158
|
+
break
|
|
159
|
+
finally:
|
|
160
|
+
termios.tcsetattr(stdin_fd, termios.TCSADRAIN, old_attrs)
|
|
161
|
+
sock.close()
|
|
162
|
+
# Leave the cursor on a fresh line after raw-mode teardown.
|
|
163
|
+
logger.info("\r\nDetached from %s.", name)
|
|
164
|
+
return 0
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def attach(name: str, namespace: str | None = None) -> int:
|
|
168
|
+
"""Attach to instance ``name``'s console. Returns an exit code."""
|
|
169
|
+
require_root()
|
|
170
|
+
|
|
171
|
+
container_dir, mode = resolve_any(name, namespace)
|
|
172
|
+
if mode is None:
|
|
173
|
+
mode = read_mode(container_dir)
|
|
174
|
+
|
|
175
|
+
if mode == "vm":
|
|
176
|
+
return _relay_serial(name, container_dir)
|
|
177
|
+
|
|
178
|
+
if mode == "pve-vm":
|
|
179
|
+
vmid = (container_dir / "kento-vmid").read_text().strip()
|
|
180
|
+
return subprocess.run(["qm", "terminal", vmid]).returncode
|
|
181
|
+
|
|
182
|
+
if mode == "pve":
|
|
183
|
+
# pve-lxc: the instance directory name IS the VMID.
|
|
184
|
+
vmid = container_dir.name
|
|
185
|
+
return subprocess.run(["pct", "enter", vmid]).returncode
|
|
186
|
+
|
|
187
|
+
# plain lxc: name is the container name; inherit stdio for interactivity.
|
|
188
|
+
return subprocess.run(["lxc-attach", "-n", name]).returncode
|