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