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/diagnose.py ADDED
@@ -0,0 +1,548 @@
1
+ """kento diagnose — read-only health/triage of kento-managed instances.
2
+
3
+ This is library code (post-split): it is SILENT (no print / sys.exit / stderr),
4
+ raises only from the KentoError hierarchy, and RETURNS its results. The CLI
5
+ catches errors, prints the formatted report, and sets the exit code.
6
+
7
+ `run_diagnostics(name=None)` runs a host-wide (or single-instance) read-only
8
+ scan and returns a structured report; `format_diagnostics(report)` renders it
9
+ as a human string. Both are pure — diagnose NEVER mutates state (stale holds,
10
+ orphans, leaked mounts are REPORTED, not reaped).
11
+
12
+ Detection reuses the existing modules wherever possible (orphan check from the
13
+ same logic list.py uses, apparmor pre-flight helpers from create.py, hold
14
+ enumeration from images.py, vmid allocation from pve.py, cloud-init detection
15
+ from cloudinit.py) so diagnose and the live code paths agree.
16
+ """
17
+
18
+ import logging
19
+ import os
20
+ import shutil
21
+ from pathlib import Path
22
+
23
+ # Bases + resolution. Bound at module level so tests can patch
24
+ # kento.diagnose.LXC_BASE / VM_BASE (mirrors test_list / test_images style).
25
+ from kento import (LXC_BASE, VM_BASE, InstanceNotFoundError, _scan_namespace,
26
+ is_running, pve_config_exists, read_mode, validate_name)
27
+ from kento.cloudinit import detect_cloudinit
28
+ from kento.create import _apparmor_active, _apparmor_parser_present
29
+ from kento.images import _guest_names, _holds
30
+ from kento.info import _read_meta
31
+ from kento.pve import _kento_recorded_vmids, is_pve, next_vmid
32
+
33
+ logger = logging.getLogger("kento")
34
+
35
+
36
+ # --- finding helper ------------------------------------------------------
37
+
38
+
39
+ def _finding(category, severity, scope, message, remediation=None):
40
+ """Build one finding dict in the canonical shape."""
41
+ return {
42
+ "category": category,
43
+ "severity": severity,
44
+ "scope": scope,
45
+ "message": message,
46
+ "remediation": remediation,
47
+ }
48
+
49
+
50
+ # --- enumeration (mirrors list.list_containers) -------------------------
51
+
52
+
53
+ def _enumerate_instances(name=None):
54
+ """Return [(container_dir, mode, display_name), ...].
55
+
56
+ Host-wide when name is None (glob both bases, exactly like
57
+ list.list_containers). When name is given, resolve that one instance
58
+ (raises InstanceNotFoundError on a miss — same as the rest of the lib).
59
+ """
60
+ if name is not None:
61
+ validate_name(name)
62
+ # Resolve against the diagnose-bound bases (so tests that patch
63
+ # kento.diagnose.LXC_BASE/VM_BASE resolve correctly). _scan_namespace
64
+ # takes the base explicitly.
65
+ hit = _scan_namespace(name, LXC_BASE)
66
+ mode_default = "lxc"
67
+ if hit is None:
68
+ hit = _scan_namespace(name, VM_BASE)
69
+ mode_default = "vm"
70
+ if hit is None:
71
+ raise InstanceNotFoundError(
72
+ f"no instance named '{name}'. "
73
+ f"Run 'kento list' to see available instances.")
74
+ mode = read_mode(hit, mode_default)
75
+ display = _read_meta(hit, "kento-name") or hit.name
76
+ return [(hit, mode, display)]
77
+
78
+ out = []
79
+ image_files = []
80
+ if LXC_BASE.is_dir():
81
+ image_files.extend(LXC_BASE.glob("*/kento-image"))
82
+ if VM_BASE.is_dir():
83
+ image_files.extend(VM_BASE.glob("*/kento-image"))
84
+ for image_file in sorted(image_files, key=lambda f: f.parent.name):
85
+ try:
86
+ container_dir = image_file.parent
87
+ mode = read_mode(container_dir)
88
+ display = (_read_meta(container_dir, "kento-name")
89
+ or container_dir.name)
90
+ out.append((container_dir, mode, display))
91
+ except OSError:
92
+ # Survive a destroy race between glob and read (mirrors list.py).
93
+ continue
94
+ return out
95
+
96
+
97
+ # --- host-level checks ---------------------------------------------------
98
+
99
+
100
+ def _check_apparmor():
101
+ """AppArmor pre-flight (host). Mirrors create.py's fail-closed gate.
102
+
103
+ error when: apparmor active + effective profile 'generated' + parser
104
+ absent (plain-lxc create would fail closed). Otherwise ok.
105
+ """
106
+ try:
107
+ active = _apparmor_active()
108
+ except (PermissionError, OSError):
109
+ return [_finding("apparmor", "info", "host",
110
+ "could not determine AppArmor state (needs root?)",
111
+ None)]
112
+ if not active:
113
+ return [_finding("apparmor", "ok", "host",
114
+ "AppArmor not active on this kernel (no-op)", None)]
115
+
116
+ profile = os.environ.get("KENTO_APPARMOR_PROFILE", "generated")
117
+ if profile != "generated":
118
+ return [_finding("apparmor", "ok", "host",
119
+ f"AppArmor active; KENTO_APPARMOR_PROFILE={profile} "
120
+ f"(generated pre-flight does not apply)", None)]
121
+ try:
122
+ parser = _apparmor_parser_present()
123
+ except (PermissionError, OSError):
124
+ return [_finding("apparmor", "info", "host",
125
+ "could not check for apparmor_parser", None)]
126
+ if not parser:
127
+ return [_finding(
128
+ "apparmor", "error", "host",
129
+ "AppArmor is active and the 'generated' profile is in effect, "
130
+ "but apparmor_parser is not on PATH — plain-lxc create will "
131
+ "fail closed.",
132
+ "install apparmor_parser (apparmor package) or set "
133
+ "KENTO_APPARMOR_PROFILE=unconfined")]
134
+ return [_finding("apparmor", "ok", "host",
135
+ "AppArmor active and apparmor_parser present", None)]
136
+
137
+
138
+ def _check_stale_holds():
139
+ """Stale image holds (host): a hold whose guest no longer exists.
140
+
141
+ READ ONLY — mirrors the prune stale logic in images.py (orphan = hold
142
+ whose held-for name is not in the live guest set) but never removes.
143
+ """
144
+ try:
145
+ holds = _holds()
146
+ guests = _guest_names()
147
+ except (PermissionError, OSError):
148
+ return [_finding("hold", "info", "host",
149
+ "could not enumerate image holds (podman/root?)",
150
+ None)]
151
+ findings = []
152
+ stale = [(n, img) for n, img in holds if n not in guests]
153
+ if not stale:
154
+ findings.append(_finding(
155
+ "hold", "ok", "host",
156
+ f"{len(holds)} image hold(s), none stale", None))
157
+ return findings
158
+ for n, img in stale:
159
+ findings.append(_finding(
160
+ "hold", "warn", "host",
161
+ f"stale image hold 'kento-hold.{n}' pins {img or '?'} but guest "
162
+ f"'{n}' no longer exists",
163
+ "kento prune"))
164
+ return findings
165
+
166
+
167
+ def _check_vmid_health():
168
+ """VMID allocation health (host, pve only).
169
+
170
+ Reports the next free vmid (pve.next_vmid) and how many recorded vmids
171
+ belong to orphans (reserved-but-not-reaped — ties to the known open
172
+ orphan-vmid item). warn if any reserved-orphan vmids exist.
173
+ """
174
+ try:
175
+ if not is_pve():
176
+ return []
177
+ except (PermissionError, OSError):
178
+ return []
179
+ try:
180
+ nxt = next_vmid()
181
+ recorded = _kento_recorded_vmids()
182
+ except (PermissionError, OSError):
183
+ return [_finding("vmid", "info", "host",
184
+ "could not compute vmid allocation (needs root?)",
185
+ None)]
186
+
187
+ # A recorded vmid whose PVE .conf is gone is reserved-but-orphaned.
188
+ reserved_orphans = []
189
+ for vmid in sorted(recorded):
190
+ # Determine the mode: a recorded vmid could be pve-lxc (dir name) or
191
+ # pve-vm (kento-vmid file). Check both config kinds defensively.
192
+ try:
193
+ lxc_gone = not pve_config_exists(str(vmid), "pve")
194
+ vm_gone = not pve_config_exists(str(vmid), "pve-vm")
195
+ except (PermissionError, OSError):
196
+ continue
197
+ if lxc_gone and vm_gone:
198
+ reserved_orphans.append(vmid)
199
+
200
+ if reserved_orphans:
201
+ return [_finding(
202
+ "vmid", "warn", "host",
203
+ f"next free vmid is {nxt}; "
204
+ f"{len(reserved_orphans)} recorded vmid(s) are reserved by "
205
+ f"orphaned kento state and will not be reassigned: "
206
+ f"{', '.join(str(v) for v in reserved_orphans)}",
207
+ "kento destroy -f <name> for the orphan(s), then re-check")]
208
+ return [_finding("vmid", "info", "host",
209
+ f"next free vmid is {nxt}; "
210
+ f"{len(recorded)} vmid(s) recorded by kento", None)]
211
+
212
+
213
+ # --- per-instance checks -------------------------------------------------
214
+
215
+
216
+ def _check_orphan(container_dir, mode, display):
217
+ """Orphan check (pve modes): state present but PVE .conf gone.
218
+
219
+ Same logic list.list_containers uses: pve-lxc uses the dir name as the
220
+ vmid; pve-vm reads kento-vmid.
221
+ """
222
+ if mode not in ("pve", "pve-vm"):
223
+ return []
224
+ if mode == "pve":
225
+ check_vmid = container_dir.name
226
+ else:
227
+ check_vmid = _read_meta(container_dir, "kento-vmid")
228
+ try:
229
+ gone = check_vmid is None or not pve_config_exists(check_vmid, mode)
230
+ except (PermissionError, OSError):
231
+ return [_finding("orphan", "info", display,
232
+ "could not check PVE config (needs root?)", None)]
233
+ if gone:
234
+ return [_finding(
235
+ "orphan", "warn", display,
236
+ f"instance '{display}' has kento state but its PVE config "
237
+ f"(vmid {check_vmid or '?'}) is gone — orphaned",
238
+ f"kento destroy -f {display}")]
239
+ return [_finding("orphan", "ok", display,
240
+ f"PVE config present (vmid {check_vmid})", None)]
241
+
242
+
243
+ def _check_portfwd(container_dir, display):
244
+ """Port-forward state (per instance) from the hook's marker files."""
245
+ err = container_dir / "kento-portfwd-error"
246
+ active = container_dir / "kento-portfwd-active"
247
+ backend = container_dir / "kento-portfwd-backend"
248
+ try:
249
+ if err.is_file():
250
+ contents = err.read_text().strip()
251
+ return [_finding(
252
+ "portfwd", "error", display,
253
+ f"port-forward setup error: {contents}",
254
+ "check the host firewall (nft/iptables) then restart the "
255
+ "instance")]
256
+ if active.is_file():
257
+ be = backend.read_text().strip() if backend.is_file() else "?"
258
+ mapping = active.read_text().strip()
259
+ return [_finding(
260
+ "portfwd", "ok", display,
261
+ f"port-forward active ({mapping}) via {be}", None)]
262
+ except (PermissionError, OSError):
263
+ return [_finding("portfwd", "info", display,
264
+ "could not read port-forward markers", None)]
265
+ # No port forwarding configured for this instance.
266
+ return []
267
+
268
+
269
+ def _check_mounts(container_dir, mode, display, running):
270
+ """Leaked/broken mounts (per instance), for STOPPED instances.
271
+
272
+ LXC: rootfs still a mountpoint => overlay leak.
273
+ VM: virtiofsd pid still alive => leaked helper.
274
+ Only meaningful when stopped; a running instance is expected to hold its
275
+ mounts / virtiofsd.
276
+ """
277
+ if running:
278
+ return []
279
+ findings = []
280
+
281
+ if mode in ("vm", "pve-vm"):
282
+ pid_file = container_dir / "kento-virtiofsd-pid"
283
+ try:
284
+ if pid_file.is_file():
285
+ pid = int(pid_file.read_text().strip())
286
+ alive = _pid_alive(pid)
287
+ if alive:
288
+ findings.append(_finding(
289
+ "mount", "warn", display,
290
+ f"instance is stopped but virtiofsd (pid {pid}) is "
291
+ f"still alive — leaked helper process",
292
+ f"kento scrub {display} or kento destroy -f {display}"))
293
+ except (PermissionError, OSError, ValueError):
294
+ findings.append(_finding(
295
+ "mount", "info", display,
296
+ "could not check virtiofsd pid (needs root?)", None))
297
+ return findings
298
+
299
+ # LXC family: check rootfs mountpoint leak. The rootfs lives next to the
300
+ # state dir; also check the state-dir merged mount if present.
301
+ candidates = [container_dir / "rootfs"]
302
+ state_text = _read_meta(container_dir, "kento-state")
303
+ if state_text:
304
+ candidates.append(Path(state_text) / "rootfs")
305
+ seen = set()
306
+ for rootfs in candidates:
307
+ key = str(rootfs)
308
+ if key in seen:
309
+ continue
310
+ seen.add(key)
311
+ try:
312
+ if os.path.ismount(rootfs):
313
+ findings.append(_finding(
314
+ "mount", "warn", display,
315
+ f"instance is stopped but {rootfs} is still a mountpoint "
316
+ f"— leaked overlay mount",
317
+ f"kento scrub {display} or kento destroy -f {display}"))
318
+ except (PermissionError, OSError):
319
+ findings.append(_finding(
320
+ "mount", "info", display,
321
+ "could not check rootfs mount (needs root?)", None))
322
+ return findings
323
+
324
+
325
+ def _pid_alive(pid):
326
+ """True if pid is alive (signal 0). EPERM means it exists but is owned
327
+ by another user — treat as alive. Wrapped so tests/no-root degrade."""
328
+ try:
329
+ os.kill(pid, 0)
330
+ return True
331
+ except ProcessLookupError:
332
+ return False
333
+ except PermissionError:
334
+ return True
335
+
336
+
337
+ def _check_network(container_dir, mode, display, running):
338
+ """networkd drop-ins / veth (per instance, best-effort).
339
+
340
+ Verify kento's expected guest drop-ins exist in the overlay upper for
341
+ static / nested instances. Host-veth-on-bridge (H-B) is a runtime check
342
+ needing networkctl/ip — only flag it as a manual check.
343
+ """
344
+ findings = []
345
+ try:
346
+ state_text = _read_meta(container_dir, "kento-state")
347
+ state_dir = Path(state_text) if state_text else container_dir
348
+ net_dir = state_dir / "upper" / "etc" / "systemd" / "network"
349
+
350
+ # kento-net (when present) holds the static network lines written at
351
+ # create time (ip=/gateway=/dns=/...); a static *IP* is configured
352
+ # exactly when it carries an `ip=` line — which is also the condition
353
+ # under which create injects 05-kento-static.network.
354
+ net = _read_meta(container_dir, "kento-net")
355
+ has_static_ip = bool(net) and any(
356
+ line.startswith("ip=") for line in net.splitlines())
357
+ if has_static_ip:
358
+ dropin = net_dir / "05-kento-static.network"
359
+ if not dropin.exists():
360
+ findings.append(_finding(
361
+ "network", "warn", display,
362
+ "static network configured but the kento drop-in "
363
+ "05-kento-static.network is missing from the overlay "
364
+ "upper — guest may not get its static address",
365
+ f"kento scrub {display} and recreate, or re-run create"))
366
+ else:
367
+ findings.append(_finding(
368
+ "network", "ok", display,
369
+ "static network drop-in present", None))
370
+
371
+ nesting = _read_meta(container_dir, "kento-nesting")
372
+ if nesting == "1":
373
+ dropin = net_dir / "10-kento-nested-veth.network"
374
+ if not dropin.exists():
375
+ findings.append(_finding(
376
+ "network", "info", display,
377
+ "nesting enabled but 10-kento-nested-veth.network is not "
378
+ "in the overlay upper (may be image-baked or injected at "
379
+ "start)", None))
380
+ except (PermissionError, OSError):
381
+ return [_finding("network", "info", display,
382
+ "could not inspect network drop-ins", None)]
383
+
384
+ # H-B host-veth enslavement is a runtime check; only attempt when running
385
+ # and a tool is present, otherwise note it as a manual check.
386
+ if running:
387
+ tool = shutil.which("networkctl") or shutil.which("ip")
388
+ if tool is None:
389
+ findings.append(_finding(
390
+ "network", "info", display,
391
+ "host-veth-on-bridge is a runtime check; networkctl/ip not "
392
+ "on PATH so it was skipped", None))
393
+ else:
394
+ findings.append(_finding(
395
+ "network", "info", display,
396
+ f"host-veth-on-bridge can be checked manually with "
397
+ f"{os.path.basename(tool)} (networkctl status <hostveth>)",
398
+ None))
399
+ return findings
400
+
401
+
402
+ def _check_cloudinit(container_dir, display):
403
+ """Cloud-init root-ssh footgun (per instance), advisory.
404
+
405
+ If created from a cloud-init image AND a root ssh key was injected →
406
+ advisory. Defensive: skip silently if layers can't be resolved.
407
+ """
408
+ authorized = container_dir / "kento-authorized-keys"
409
+ try:
410
+ if not authorized.is_file():
411
+ return []
412
+ ssh_user = _read_meta(container_dir, "kento-ssh-user") or "root"
413
+ if ssh_user != "root":
414
+ return []
415
+ layers = _read_meta(container_dir, "kento-layers")
416
+ if not layers:
417
+ return []
418
+ if not detect_cloudinit(layers):
419
+ return []
420
+ except (PermissionError, OSError):
421
+ return []
422
+ return [_finding(
423
+ "cloudinit", "warn", display,
424
+ f"SSH keys were injected for 'root' on a cloud-init image. Cloud "
425
+ f"images typically disable root SSH login, so the key may not take "
426
+ f"effect.",
427
+ "recreate with --ssh-key-user <user> (e.g. 'debian') if root login "
428
+ "does not work")]
429
+
430
+
431
+ # --- public API ----------------------------------------------------------
432
+
433
+
434
+ def run_diagnostics(name=None):
435
+ """Run a read-only health/triage scan and return a structured report.
436
+
437
+ name=None → host-wide scan of all instances (both namespaces) plus
438
+ host-level checks. name=<instance> → host-level checks plus the
439
+ instance checks for that one resolved instance (raises
440
+ InstanceNotFoundError on a miss).
441
+
442
+ Returns:
443
+ {"checks": [<finding>, ...], "problem_count": int,
444
+ "instances_scanned": int}
445
+ where each finding is
446
+ {"category", "severity" ("ok"|"info"|"warn"|"error"),
447
+ "scope" ("host" | "<instance-name>"), "message", "remediation"}.
448
+
449
+ Pure / read-only / silent. Per-check failures degrade to "info"
450
+ findings rather than raising (resolution misses still raise).
451
+ """
452
+ instances = _enumerate_instances(name)
453
+
454
+ checks = []
455
+
456
+ # Host-level checks run regardless of name.
457
+ checks.extend(_check_apparmor())
458
+ checks.extend(_check_stale_holds())
459
+ checks.extend(_check_vmid_health())
460
+
461
+ for container_dir, mode, display in instances:
462
+ try:
463
+ running = is_running(container_dir, mode)
464
+ except (PermissionError, OSError):
465
+ running = False
466
+ checks.append(_finding(
467
+ "status", "info", display,
468
+ "could not determine running state (needs root?)", None))
469
+ checks.extend(_check_orphan(container_dir, mode, display))
470
+ checks.extend(_check_portfwd(container_dir, display))
471
+ checks.extend(_check_mounts(container_dir, mode, display, running))
472
+ checks.extend(_check_network(container_dir, mode, display, running))
473
+ checks.extend(_check_cloudinit(container_dir, display))
474
+
475
+ problem_count = sum(1 for f in checks if f["severity"] in ("warn", "error"))
476
+
477
+ return {
478
+ "checks": checks,
479
+ "problem_count": problem_count,
480
+ "instances_scanned": len(instances),
481
+ }
482
+
483
+
484
+ _SEVERITY_LABEL = {
485
+ "ok": "OK",
486
+ "info": "INFO",
487
+ "warn": "WARN",
488
+ "error": "ERROR",
489
+ }
490
+
491
+
492
+ def format_diagnostics(report):
493
+ """Render a diagnostics report as a human-readable multi-line string.
494
+
495
+ Host-level findings first, then per-instance. "ok" checks are summarized
496
+ compactly; warn/error findings are shown in detail with remediation.
497
+ Returns the joined string (the caller prints it).
498
+ """
499
+ checks = report.get("checks", [])
500
+ problem_count = report.get("problem_count", 0)
501
+ scanned = report.get("instances_scanned", 0)
502
+
503
+ host = [f for f in checks if f["scope"] == "host"]
504
+ # Stable per-instance grouping in first-seen order.
505
+ inst_order = []
506
+ by_inst = {}
507
+ for f in checks:
508
+ if f["scope"] == "host":
509
+ continue
510
+ if f["scope"] not in by_inst:
511
+ by_inst[f["scope"]] = []
512
+ inst_order.append(f["scope"])
513
+ by_inst[f["scope"]].append(f)
514
+
515
+ lines = []
516
+
517
+ # Summary header.
518
+ if problem_count == 0:
519
+ lines.append(f"All clear — {len(checks)} checks, 0 problems "
520
+ f"({scanned} instance(s) scanned).")
521
+ else:
522
+ lines.append(f"{len(checks)} checks, {problem_count} problem(s) "
523
+ f"({scanned} instance(s) scanned).")
524
+
525
+ def _emit_group(title, group):
526
+ if not group:
527
+ return
528
+ problems = [f for f in group if f["severity"] in ("warn", "error")]
529
+ oks = [f for f in group if f["severity"] == "ok"]
530
+ infos = [f for f in group if f["severity"] == "info"]
531
+ lines.append("")
532
+ lines.append(title)
533
+ for f in problems:
534
+ lines.append(f" [{_SEVERITY_LABEL[f['severity']]}] "
535
+ f"{f['category']}: {f['message']}")
536
+ if f["remediation"]:
537
+ lines.append(f" fix: {f['remediation']}")
538
+ for f in infos:
539
+ lines.append(f" [INFO] {f['category']}: {f['message']}")
540
+ if oks:
541
+ cats = ", ".join(sorted({f["category"] for f in oks}))
542
+ lines.append(f" [OK] {len(oks)} check(s) passed: {cats}")
543
+
544
+ _emit_group("Host:", host)
545
+ for scope in inst_order:
546
+ _emit_group(f"Instance {scope}:", by_inst[scope])
547
+
548
+ return "\n".join(lines)
kento/errors.py ADDED
@@ -0,0 +1,48 @@
1
+ """Kento library exception hierarchy.
2
+
3
+ The library RAISES these; the CLI (kento-cli) catches them, prints, and sets the
4
+ exit code. Every library-raised error subclasses KentoError so callers can do
5
+ `except KentoError`. Messages carry NO "Error: " prefix — the CLI adds presentation.
6
+ """
7
+
8
+
9
+ class KentoError(Exception):
10
+ """Base for every error the kento library raises."""
11
+
12
+
13
+ class ValidationError(KentoError):
14
+ """Invalid user-supplied input (name, MAC, port, IP, memory, cores, ...)."""
15
+
16
+
17
+ class InstanceNotFoundError(KentoError):
18
+ """A referenced instance does not exist."""
19
+
20
+
21
+ class InstanceExistsError(KentoError):
22
+ """An instance with the requested name already exists."""
23
+
24
+
25
+ class ImageNotFoundError(KentoError):
26
+ """A referenced OCI image is not present locally."""
27
+
28
+
29
+ class ModeError(KentoError):
30
+ """Operation invalid for the instance's mode / PVE / VM context."""
31
+
32
+
33
+ class StateError(KentoError):
34
+ """Instance is in the wrong state for the operation, or a pre-flight
35
+ (privilege, apparmor, mount) failed."""
36
+
37
+
38
+ class SubprocessError(KentoError):
39
+ """An underlying command (pct/qm/lxc/virtiofsd/podman) failed.
40
+
41
+ Carries the command and return code when available so the CLI can render them.
42
+ """
43
+
44
+ def __init__(self, message: str, *, cmd: list[str] | None = None,
45
+ returncode: int | None = None):
46
+ super().__init__(message)
47
+ self.cmd = cmd
48
+ self.returncode = returncode
kento/exec_cmd.py ADDED
@@ -0,0 +1,50 @@
1
+ """Run a command inside a kento-managed instance (non-interactive exec).
2
+
3
+ Dispatch per mode:
4
+ - lxc -> lxc-attach -n <name> -- cmd... (inherited stdio)
5
+ - pve -> pct exec <vmid> -- cmd... (pve-lxc; vmid is the dir name)
6
+ - vm -> error (no in-guest agent; use SSH or 'kento attach')
7
+ - pve-vm -> error (same)
8
+
9
+ The module is named exec_cmd to avoid any confusion with the ``exec`` builtin.
10
+ """
11
+
12
+ import logging
13
+ import subprocess
14
+ from pathlib import Path
15
+
16
+ from kento import read_mode, require_root, resolve_any
17
+ from kento.errors import ModeError, ValidationError
18
+
19
+ logger = logging.getLogger("kento")
20
+
21
+
22
+ def exec_cmd(name: str, command: list[str],
23
+ namespace: str | None = None) -> int:
24
+ """Run ``command`` inside instance ``name``. Returns an exit code."""
25
+ require_root()
26
+
27
+ if not command:
28
+ raise ValidationError(
29
+ "exec requires a command, e.g. "
30
+ "'kento exec <name> -- ls -la'"
31
+ )
32
+
33
+ container_dir, mode = resolve_any(name, namespace)
34
+ if mode is None:
35
+ mode = read_mode(container_dir)
36
+
37
+ if mode in ("vm", "pve-vm"):
38
+ raise ModeError(
39
+ "'kento exec' is not supported for VM instances "
40
+ "(no in-guest agent). Use SSH, or 'kento attach <name>' for an "
41
+ "interactive console."
42
+ )
43
+
44
+ if mode == "pve":
45
+ # pve-lxc: the instance directory name IS the VMID.
46
+ vmid = container_dir.name
47
+ return subprocess.run(["pct", "exec", vmid, "--", *command]).returncode
48
+
49
+ # plain lxc: name is the container name; inherit stdio.
50
+ return subprocess.run(["lxc-attach", "-n", name, "--", *command]).returncode
kento/hook.py ADDED
@@ -0,0 +1,28 @@
1
+ """Generate per-container LXC hook scripts."""
2
+
3
+ from pathlib import Path
4
+
5
+ _TEMPLATE = (Path(__file__).parent / "hook.sh").read_text()
6
+
7
+
8
+ def generate_hook(container_dir: Path, layers: str, name: str,
9
+ state_dir: Path | None = None) -> str:
10
+ """Return a hook script with baked-in paths for a container.
11
+
12
+ state_dir is where upper/work live. Defaults to container_dir if not given.
13
+ """
14
+ sd = state_dir or container_dir
15
+ return (_TEMPLATE
16
+ .replace("@@NAME@@", str(name))
17
+ .replace("@@CONTAINER_DIR@@", str(container_dir))
18
+ .replace("@@STATE_DIR@@", str(sd))
19
+ .replace("@@LAYERS@@", str(layers)))
20
+
21
+
22
+ def write_hook(container_dir: Path, layers: str, name: str,
23
+ state_dir: Path | None = None) -> Path:
24
+ """Generate and write the hook script into the container directory."""
25
+ hook_path = container_dir / "kento-hook"
26
+ hook_path.write_text(generate_hook(container_dir, layers, name, state_dir))
27
+ hook_path.chmod(0o755)
28
+ return hook_path