kento-core 1.6.0.dev1__tar.gz → 1.6.0.dev2__tar.gz

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.
Files changed (73) hide show
  1. {kento_core-1.6.0.dev1/src/kento_core.egg-info → kento_core-1.6.0.dev2}/PKG-INFO +1 -1
  2. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/pyproject.toml +1 -1
  3. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/src/kento/diagnose.py +13 -11
  4. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/src/kento/list.py +13 -3
  5. kento_core-1.6.0.dev2/src/kento/reconcile.py +498 -0
  6. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2/src/kento_core.egg-info}/PKG-INFO +1 -1
  7. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/src/kento_core.egg-info/SOURCES.txt +3 -0
  8. kento_core-1.6.0.dev2/tests/test_adopt.py +348 -0
  9. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/tests/test_diagnose.py +8 -1
  10. kento_core-1.6.0.dev2/tests/test_reconcile.py +443 -0
  11. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/LICENSE.md +0 -0
  12. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/README.md +0 -0
  13. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/setup.cfg +0 -0
  14. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/src/kento/__init__.py +0 -0
  15. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/src/kento/attach.py +0 -0
  16. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/src/kento/cloudinit.py +0 -0
  17. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/src/kento/create.py +0 -0
  18. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/src/kento/defaults.py +0 -0
  19. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/src/kento/destroy.py +0 -0
  20. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/src/kento/errors.py +0 -0
  21. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/src/kento/exec_cmd.py +0 -0
  22. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/src/kento/hook.py +0 -0
  23. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/src/kento/hook.sh +0 -0
  24. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/src/kento/images.py +0 -0
  25. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/src/kento/info.py +0 -0
  26. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/src/kento/inject.py +0 -0
  27. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/src/kento/inject.sh +0 -0
  28. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/src/kento/layers.py +0 -0
  29. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/src/kento/locking.py +0 -0
  30. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/src/kento/logs.py +0 -0
  31. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/src/kento/lxc_hook.py +0 -0
  32. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/src/kento/pve.py +0 -0
  33. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/src/kento/reset.py +0 -0
  34. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/src/kento/set_cmd.py +0 -0
  35. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/src/kento/start.py +0 -0
  36. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/src/kento/stop.py +0 -0
  37. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/src/kento/subprocess_util.py +0 -0
  38. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/src/kento/suspend.py +0 -0
  39. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/src/kento/vm.py +0 -0
  40. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/src/kento/vm_hook.py +0 -0
  41. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/src/kento_core.egg-info/dependency_links.txt +0 -0
  42. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/src/kento_core.egg-info/top_level.txt +0 -0
  43. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/tests/test_attach.py +0 -0
  44. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/tests/test_cloudinit.py +0 -0
  45. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/tests/test_create.py +0 -0
  46. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/tests/test_create_locking.py +0 -0
  47. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/tests/test_create_passthrough.py +0 -0
  48. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/tests/test_defaults.py +0 -0
  49. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/tests/test_destroy.py +0 -0
  50. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/tests/test_errors.py +0 -0
  51. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/tests/test_exec.py +0 -0
  52. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/tests/test_hook.py +0 -0
  53. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/tests/test_image_hold.py +0 -0
  54. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/tests/test_images.py +0 -0
  55. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/tests/test_info.py +0 -0
  56. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/tests/test_init.py +0 -0
  57. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/tests/test_inject.py +0 -0
  58. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/tests/test_layers.py +0 -0
  59. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/tests/test_list.py +0 -0
  60. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/tests/test_locking.py +0 -0
  61. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/tests/test_logs.py +0 -0
  62. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/tests/test_lxc_hook.py +0 -0
  63. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/tests/test_pve.py +0 -0
  64. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/tests/test_reset.py +0 -0
  65. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/tests/test_run_or_die_integration.py +0 -0
  66. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/tests/test_set.py +0 -0
  67. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/tests/test_start.py +0 -0
  68. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/tests/test_stop.py +0 -0
  69. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/tests/test_subprocess_util.py +0 -0
  70. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/tests/test_suspend.py +0 -0
  71. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/tests/test_validate_name.py +0 -0
  72. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/tests/test_vm.py +0 -0
  73. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/tests/test_vm_hook.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kento-core
3
- Version: 1.6.0.dev1
3
+ Version: 1.6.0.dev2
4
4
  Summary: Kento core library — compose OCI images into LXC/VM system containers (importable engine)
5
5
  License-Expression: GPL-3.0-only
6
6
  Requires-Python: >=3.11
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "kento-core"
7
- version = "1.6.0.dev1"
7
+ version = "1.6.0.dev2"
8
8
  description = "Kento core library — compose OCI images into LXC/VM system containers (importable engine)"
9
9
  requires-python = ">=3.11"
10
10
  license = "GPL-3.0-only"
@@ -29,6 +29,7 @@ from kento.create import _apparmor_active, _apparmor_parser_present
29
29
  from kento.images import _guest_names, _holds
30
30
  from kento.info import _read_meta
31
31
  from kento.pve import _kento_recorded_vmids, is_pve, next_vmid
32
+ from kento.reconcile import _is_orphan, _orphan_vmid
32
33
 
33
34
  logger = logging.getLogger("kento")
34
35
 
@@ -204,7 +205,8 @@ def _check_vmid_health():
204
205
  f"{len(reserved_orphans)} recorded vmid(s) are reserved by "
205
206
  f"orphaned kento state and will not be reassigned: "
206
207
  f"{', '.join(str(v) for v in reserved_orphans)}",
207
- "kento destroy -f <name> for the orphan(s), then re-check")]
208
+ "kento adopt <name> to heal, or kento destroy -f <name> to "
209
+ "discard, the orphan(s), then re-check")]
208
210
  return [_finding("vmid", "info", "host",
209
211
  f"next free vmid is {nxt}; "
210
212
  f"{len(recorded)} vmid(s) recorded by kento", None)]
@@ -216,18 +218,17 @@ def _check_vmid_health():
216
218
  def _check_orphan(container_dir, mode, display):
217
219
  """Orphan check (pve modes): state present but PVE .conf gone.
218
220
 
219
- Same logic list.list_containers uses: pve-lxc uses the dir name as the
220
- vmid; pve-vm reads kento-vmid.
221
+ Detection is shared with list.list_containers via reconcile._is_orphan
222
+ (pve-lxc uses the dir name as the vmid; pve-vm reads kento-vmid). The
223
+ diagnose-bound pve_config_exists is passed in so the predicate probes
224
+ through the same name diagnose's tests patch.
221
225
  """
222
226
  if mode not in ("pve", "pve-vm"):
223
227
  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):
228
+ check_vmid = _orphan_vmid(container_dir, mode)
229
+ gone = _is_orphan(container_dir, mode, pve_config_exists)
230
+ if gone is None:
231
+ # Indeterminate config probe (PermissionError/OSError).
231
232
  return [_finding("orphan", "info", display,
232
233
  "could not check PVE config (needs root?)", None)]
233
234
  if gone:
@@ -235,7 +236,8 @@ def _check_orphan(container_dir, mode, display):
235
236
  "orphan", "warn", display,
236
237
  f"instance '{display}' has kento state but its PVE config "
237
238
  f"(vmid {check_vmid or '?'}) is gone — orphaned",
238
- f"kento destroy -f {display}")]
239
+ f"kento adopt {display} to heal it, or "
240
+ f"kento destroy -f {display} to discard it")]
239
241
  return [_finding("orphan", "ok", display,
240
242
  f"PVE config present (vmid {check_vmid})", None)]
241
243
 
@@ -6,6 +6,7 @@ from pathlib import Path
6
6
 
7
7
  from kento import LXC_BASE, VM_BASE, is_running, pve_config_exists, read_mode
8
8
  from kento.info import _get_ssh_host_key_fingerprints
9
+ from kento.reconcile import _is_orphan
9
10
 
10
11
 
11
12
  def list_containers(scope: str | None = None, show_size: bool = False,
@@ -54,10 +55,19 @@ def list_containers(scope: str | None = None, show_size: bool = False,
54
55
 
55
56
  # For PVE modes, surface an orphaned instance (PVE config gone,
56
57
  # destroyed out-of-band) as "orphan" so the user can see it and
57
- # clean it up with `destroy -f`.
58
+ # clean it up with `destroy -f`. Detection is shared with
59
+ # diagnose via reconcile._is_orphan (pve-lxc uses the dir name as
60
+ # the vmid; pve-vm reads kento-vmid). Pass list's own (test-
61
+ # patchable) pve_config_exists binding so the predicate probes
62
+ # through the same name list already patches.
58
63
  if mode in ("pve", "pve-vm"):
59
- check_vmid = container_dir.name if mode == "pve" else vmid
60
- if check_vmid is None or not pve_config_exists(check_vmid, mode):
64
+ orphaned = _is_orphan(container_dir, mode, pve_config_exists)
65
+ if orphaned is None:
66
+ # Indeterminate config probe (PermissionError/OSError). The
67
+ # prior inline code let that exception propagate to the
68
+ # outer `except OSError` and skip the entry; preserve that.
69
+ raise OSError("indeterminate PVE config check")
70
+ if orphaned:
61
71
  status = "orphan"
62
72
  else:
63
73
  status = "running" if is_running(container_dir, mode) else "stopped"
@@ -0,0 +1,498 @@
1
+ """Orphan reconcile — shared detection for orphaned kento PVE instances.
2
+
3
+ An **orphan** is a kento-managed PVE instance (mode ``pve`` or ``pve-vm``)
4
+ whose state dir survives but whose PVE ``.conf`` was destroyed out-of-band
5
+ (``pct``/``qm destroy``, a pmxcfs glitch, or a crash mid-operation). Plain
6
+ ``lxc``/``vm`` instances have no PVE config and therefore can never orphan.
7
+
8
+ This module single-sources the gone-check that was previously duplicated
9
+ inline in ``list.list_containers``, ``diagnose._check_orphan``, and
10
+ ``diagnose._check_vmid_health``. Those call sites delegate here so the
11
+ detection logic lives in one place.
12
+
13
+ Library code: silent (no print / sys.exit / stderr), best-effort, read-only.
14
+ Nothing here reaps or mutates state — that is Phase 2 (``reap_orphans``).
15
+
16
+ Safety invariant: an instance is classified as an orphan ONLY when
17
+ ``pve_config_exists(...)`` returns a *definitive* ``False``. If that probe
18
+ raises ``PermissionError``/``OSError`` (e.g. it needs root) the result is
19
+ *indeterminate* and the instance is NEVER classified as an orphan.
20
+ """
21
+
22
+ import logging
23
+ from pathlib import Path
24
+ from typing import Callable
25
+
26
+ from kento import (LXC_BASE, VM_BASE, pve_config_exists, read_mode,
27
+ require_root, resolve_any)
28
+ from kento.errors import ModeError, StateError
29
+ from kento.info import _read_meta
30
+ from kento.locking import kento_lock
31
+
32
+ logger = logging.getLogger("kento")
33
+
34
+
35
+ def _orphan_vmid(container_dir: Path, mode: str) -> str | None:
36
+ """Return the vmid used for the PVE-config gone-check, or None.
37
+
38
+ Mirrors list.py / diagnose.py exactly:
39
+ - pve-lxc (``pve``): the container DIR NAME is the vmid.
40
+ - pve-vm (``pve-vm``): the vmid is recorded in the ``kento-vmid`` file.
41
+
42
+ Returns None for a pve-vm whose ``kento-vmid`` file is missing/empty (the
43
+ caller treats a None vmid as gone, matching the existing inline logic).
44
+ """
45
+ if mode == "pve":
46
+ return container_dir.name
47
+ return _read_meta(container_dir, "kento-vmid")
48
+
49
+
50
+ def _is_orphan(container_dir: Path, mode: str,
51
+ config_exists: Callable[[str, str], bool] | None = None,
52
+ ) -> bool | None:
53
+ """Tri-state orphan predicate for one PVE instance.
54
+
55
+ Returns:
56
+ - ``True`` — definitively orphaned (PVE config gone / no vmid).
57
+ - ``False`` — definitively healthy (PVE config present).
58
+ - ``None`` — indeterminate (the config probe raised; needs root?).
59
+
60
+ Non-PVE modes return ``False`` (cannot orphan). ``config_exists`` is
61
+ injected so each call site can pass its own (test-patchable) binding of
62
+ ``pve_config_exists`` — preserving the existing per-module patch points.
63
+ Defaults (None) to this module's ``pve_config_exists``, looked up at call
64
+ time so ``kento.reconcile.pve_config_exists`` stays patchable.
65
+ """
66
+ if config_exists is None:
67
+ config_exists = pve_config_exists
68
+ if mode not in ("pve", "pve-vm"):
69
+ return False
70
+ check_vmid = _orphan_vmid(container_dir, mode)
71
+ if check_vmid is None:
72
+ # No vmid recorded => the existing inline logic treats this as gone.
73
+ return True
74
+ try:
75
+ return not config_exists(check_vmid, mode)
76
+ except (PermissionError, OSError):
77
+ # Indeterminate: never classify as orphan on uncertainty.
78
+ return None
79
+
80
+
81
+ def find_orphans(scope: str | None = None) -> list[dict]:
82
+ """Enumerate kento PVE instances whose PVE ``.conf`` is definitively gone.
83
+
84
+ Returns a list of dicts::
85
+
86
+ {"name", "vmid", "mode", "container_dir", "image"}
87
+
88
+ where ``mode`` is the raw kento mode (``"pve"`` or ``"pve-vm"``),
89
+ ``vmid`` is an ``int`` when it parses (else the raw string, or None),
90
+ ``container_dir`` is a ``Path``, ``name`` is the display name
91
+ (``kento-name`` file or the dir name), and ``image`` is the recorded
92
+ image reference (or None if unreadable).
93
+
94
+ Only modes ``pve``/``pve-vm`` can orphan; plain ``lxc``/``vm`` are never
95
+ returned. ``scope=None`` scans both namespaces; ``scope="lxc"`` /
96
+ ``scope="vm"`` narrows to one base (mirroring how list/diagnose scope).
97
+
98
+ Safety: an instance is included ONLY when its PVE config is definitively
99
+ gone. An indeterminate probe (``PermissionError``/``OSError``) → skipped,
100
+ never returned. Best-effort and read-only: missing base dirs, non-integer
101
+ dir names, missing metadata files, and per-instance ``OSError`` are all
102
+ tolerated, never fatal.
103
+ """
104
+ image_files = []
105
+ if scope in (None, "lxc"):
106
+ if LXC_BASE.is_dir():
107
+ image_files.extend(LXC_BASE.glob("*/kento-image"))
108
+ if scope in (None, "vm"):
109
+ if VM_BASE.is_dir():
110
+ image_files.extend(VM_BASE.glob("*/kento-image"))
111
+
112
+ orphans: list[dict] = []
113
+ for image_file in sorted(image_files, key=lambda f: f.parent.name):
114
+ # Mirror list.py: a concurrent destroy can race between the glob and
115
+ # the reads below. Skip the bad entry rather than aborting.
116
+ try:
117
+ container_dir = image_file.parent
118
+ mode = read_mode(container_dir)
119
+ if mode not in ("pve", "pve-vm"):
120
+ continue
121
+
122
+ if _is_orphan(container_dir, mode) is not True:
123
+ # False (healthy) or None (indeterminate) → not an orphan.
124
+ continue
125
+
126
+ display = _read_meta(container_dir, "kento-name") or container_dir.name
127
+ image = _read_meta(container_dir, "kento-image")
128
+ raw_vmid = _orphan_vmid(container_dir, mode)
129
+ vmid: int | str | None
130
+ if raw_vmid is None:
131
+ vmid = None
132
+ else:
133
+ try:
134
+ vmid = int(raw_vmid)
135
+ except (TypeError, ValueError):
136
+ vmid = raw_vmid
137
+
138
+ orphans.append({
139
+ "name": display,
140
+ "vmid": vmid,
141
+ "mode": mode,
142
+ "container_dir": container_dir,
143
+ "image": image,
144
+ })
145
+ except OSError:
146
+ continue
147
+
148
+ return orphans
149
+
150
+
151
+ def reap_orphans(reap: bool = False, scope: str | None = None) -> list[dict]:
152
+ """Discard orphaned PVE instances (state dir survives, PVE .conf gone).
153
+
154
+ ``reap=False`` (default): dry-run — enumerate orphans, reap nothing.
155
+ ``reap=True``: ``destroy(force=True)`` each orphan.
156
+
157
+ Returns a list of dicts::
158
+
159
+ {"name", "vmid", "mode", "reaped": bool, "error": str | None}
160
+
161
+ one entry per orphan found, in the order ``find_orphans`` returns them.
162
+ ``reaped``/``error`` reflect what happened: on a dry-run every entry has
163
+ ``reaped=False`` / ``error=None``; under ``reap=True`` a destroyed orphan
164
+ has ``reaped=True`` / ``error=None`` and a failed one ``reaped=False`` with
165
+ the exception text in ``error``.
166
+
167
+ Safety: this acts ONLY on what ``find_orphans(scope)`` returns (definitively
168
+ orphaned instances). It performs no independent enumeration, so the
169
+ "never reap a healthy/indeterminate instance" invariant lives entirely in
170
+ ``find_orphans``. Per-orphan failure is isolated — an exception from
171
+ ``destroy`` is caught, recorded in ``error``, and reaping continues to the
172
+ next orphan. This never raises for a single failure.
173
+
174
+ Library code: silent (no print / sys.exit / stderr). The CLI renders the
175
+ result via ``format_reap``.
176
+ """
177
+ orphans = find_orphans(scope)
178
+
179
+ # Import lazily (mirrors how destroy is pulled in across the lib), but
180
+ # ONCE before the loop — not per-orphan. Inside the loop body a failed
181
+ # import would be swallowed by the per-orphan `except Exception` and
182
+ # mis-reported once per orphan; hoisting it makes such a failure surface
183
+ # cleanly. The per-orphan try/except still isolates each destroy() call.
184
+ if reap:
185
+ from kento.destroy import destroy
186
+
187
+ results: list[dict] = []
188
+ for o in orphans:
189
+ entry = {
190
+ "name": o["name"],
191
+ "vmid": o["vmid"],
192
+ "mode": o["mode"],
193
+ "reaped": False,
194
+ "error": None,
195
+ }
196
+ if reap:
197
+ # Pass the already-resolved container_dir/mode so destroy does
198
+ # not have to re-resolve the name.
199
+ try:
200
+ destroy(o["name"], force=True,
201
+ container_dir=o["container_dir"], mode=o["mode"])
202
+ entry["reaped"] = True
203
+ except Exception as e: # isolate per-orphan failure
204
+ entry["error"] = str(e) or e.__class__.__name__
205
+ logger.warning("failed to reap orphan %s (vmid %s): %s",
206
+ o["name"], o["vmid"], e)
207
+ results.append(entry)
208
+
209
+ return results
210
+
211
+
212
+ def format_reap(results: list[dict], reaped: bool) -> str:
213
+ """Render a ``reap_orphans`` result list as a human-readable string.
214
+
215
+ ``reaped`` mirrors the ``reap`` argument passed to ``reap_orphans``:
216
+ ``False`` renders the dry-run plan (what WOULD be destroyed, with a
217
+ ``--yes`` hint); ``True`` reports each orphan as reaped or failed.
218
+ Returns the joined string (no trailing newline); the caller prints it.
219
+ """
220
+ if not results:
221
+ return "Orphans: none found."
222
+
223
+ lines = ["Orphans:"]
224
+ if not reaped:
225
+ lines.append(f" Dry run — nothing destroyed. {len(results)} orphaned "
226
+ f"instance(s) WOULD be destroyed (state discarded):")
227
+ for r in results:
228
+ lines.append(f" {r['name']} (vmid {r['vmid']}, {r['mode']})")
229
+ lines.append(" Run 'kento prune --orphans --yes' to destroy them.")
230
+ return "\n".join(lines)
231
+
232
+ reaped_n = sum(1 for r in results if r["reaped"])
233
+ failed = [r for r in results if not r["reaped"]]
234
+ for r in results:
235
+ if r["reaped"]:
236
+ lines.append(f" reaped {r['name']} (vmid {r['vmid']}, {r['mode']})")
237
+ else:
238
+ lines.append(f" FAILED {r['name']} (vmid {r['vmid']}, {r['mode']}): "
239
+ f"{r['error']}")
240
+ lines.append(f"Destroyed {reaped_n} orphan(s)"
241
+ + (f", {len(failed)} failed." if failed else "."))
242
+ return "\n".join(lines)
243
+
244
+
245
+ # ---------------------------------------------------------------------------
246
+ # Adopt: heal one orphan by regenerating its missing PVE config from state.
247
+ # ---------------------------------------------------------------------------
248
+
249
+ def _adopt_vmid(container_dir: Path, mode: str) -> int:
250
+ """Recover the integer vmid for an orphan from surviving state.
251
+
252
+ pve-lxc: the container DIR NAME is the vmid (mirrors create/destroy).
253
+ pve-vm: the vmid is recorded in the ``kento-vmid`` file.
254
+
255
+ Raises StateError if the vmid is missing or non-integer — adopt cannot
256
+ rebuild a config without knowing which slot to write.
257
+ """
258
+ if mode == "pve":
259
+ raw = container_dir.name
260
+ else:
261
+ raw = _read_meta(container_dir, "kento-vmid")
262
+ if raw is None:
263
+ raise StateError(
264
+ f"cannot recover vmid for '{container_dir.name}' from surviving "
265
+ "state; nothing to adopt."
266
+ )
267
+ try:
268
+ return int(raw)
269
+ except (TypeError, ValueError):
270
+ raise StateError(
271
+ f"recorded vmid {raw!r} is not an integer; cannot adopt."
272
+ )
273
+
274
+
275
+ def _parse_net_meta(container_dir: Path) -> dict:
276
+ """Parse kento-net (ip=/gateway=/dns=/searchdomain=) into a dict.
277
+
278
+ Mirrors set_cmd._parse_kento_net / reset.py: each non-empty key=value
279
+ line, only the four recognized keys retained. Absent file -> all None.
280
+ """
281
+ out = {"ip": None, "gateway": None, "dns": None, "searchdomain": None}
282
+ p = container_dir / "kento-net"
283
+ if not p.is_file():
284
+ return out
285
+ for line in p.read_text().strip().splitlines():
286
+ if "=" not in line:
287
+ continue
288
+ k, v = line.split("=", 1)
289
+ if k in out:
290
+ out[k] = v or None
291
+ return out
292
+
293
+
294
+ def emit_pve_config(container_dir: Path, mode: str) -> int:
295
+ """Regenerate the snippets wrapper + hook + PVE ``.conf`` for one instance.
296
+
297
+ This is the heart of ``adopt``: it rebuilds the *derived* PVE artifacts
298
+ (the snippets-wrapper hookscript, the kento hook, and the ``.conf``
299
+ itself) from kento's surviving on-disk metadata, producing a config
300
+ byte-compatible with what ``create`` originally wrote. It does NOT mount
301
+ the rootfs, start the instance, or touch the writable layer.
302
+
303
+ ``mode`` must be ``"pve"`` (pve-lxc) or ``"pve-vm"``. Returns the vmid
304
+ written. Pure regeneration — assumes the caller has already validated
305
+ that the instance is an adoptable orphan (see ``adopt``).
306
+
307
+ Library code: silent (no print / sys.exit / stderr); raises KentoError.
308
+ """
309
+ name = _read_meta(container_dir, "kento-name") or container_dir.name
310
+ layers_file = container_dir / "kento-layers"
311
+ layers = layers_file.read_text().strip() if layers_file.is_file() else ""
312
+ state = _read_meta(container_dir, "kento-state")
313
+ state_dir = Path(state) if state else container_dir
314
+ vmid = _adopt_vmid(container_dir, mode)
315
+
316
+ if mode == "pve-vm":
317
+ from kento.vm_hook import write_vm_hook, write_snippets_wrapper
318
+ from kento.pve import generate_qm_config, write_qm_config
319
+ from kento.defaults import get_vm_defaults
320
+
321
+ # Regenerate the VM hook (overlay assembly + virtiofsd) and the
322
+ # snippets-wrapper that PVE's hookscript: field points at (always
323
+ # required for pve-vm).
324
+ write_vm_hook(container_dir, layers, name, state_dir)
325
+ hookscript_ref = write_snippets_wrapper(
326
+ vmid, container_dir / "kento-hook")
327
+
328
+ # machine/kvm are not persisted -> best-available from defaults.
329
+ vm_defaults = get_vm_defaults()
330
+ mem_raw = _read_meta(container_dir, "kento-memory")
331
+ cores_raw = _read_meta(container_dir, "kento-cores")
332
+ memory = int(mem_raw) if mem_raw else vm_defaults["memory"]
333
+ cores = int(cores_raw) if cores_raw else vm_defaults["cores"]
334
+ net_type = _read_meta(container_dir, "kento-net-type")
335
+ bridge = _read_meta(container_dir, "kento-bridge")
336
+ mac = _read_meta(container_dir, "kento-mac")
337
+
338
+ write_qm_config(
339
+ vmid,
340
+ generate_qm_config(
341
+ name, vmid, container_dir,
342
+ hookscript_ref=hookscript_ref,
343
+ memory=memory, cores=cores,
344
+ machine=vm_defaults["machine"],
345
+ kvm=vm_defaults["kvm"],
346
+ bridge=bridge, net_type=net_type, mac=mac,
347
+ ),
348
+ )
349
+ return vmid
350
+
351
+ # pve-lxc
352
+ from kento.hook import write_hook
353
+ from kento.pve import generate_pve_config, write_pve_config
354
+ from kento.create import _pve_idmap_range
355
+
356
+ write_hook(container_dir, layers, name, state_dir)
357
+
358
+ # The snippets-wrapper is only needed when the instance carries
359
+ # port/memory/cores metadata (the same condition reset.py / create use).
360
+ # Otherwise the hook is referenced directly and hookscript_ref is None.
361
+ hookscript_ref = None
362
+ if any((container_dir / f).is_file()
363
+ for f in ("kento-port", "kento-memory", "kento-cores")):
364
+ from kento.lxc_hook import write_lxc_snippets_wrapper
365
+ hookscript_ref = write_lxc_snippets_wrapper(
366
+ vmid, container_dir / "kento-hook")
367
+
368
+ net = _parse_net_meta(container_dir)
369
+ nesting_raw = _read_meta(container_dir, "kento-nesting")
370
+ nesting = nesting_raw == "1"
371
+ unprivileged = _read_meta(container_dir, "kento-unprivileged") == "1"
372
+ mem_raw = _read_meta(container_dir, "kento-memory")
373
+ cores_raw = _read_meta(container_dir, "kento-cores")
374
+
375
+ pve_conf_text = generate_pve_config(
376
+ name, vmid, container_dir,
377
+ bridge=_read_meta(container_dir, "kento-bridge"),
378
+ net_type=_read_meta(container_dir, "kento-net-type"),
379
+ nesting=nesting,
380
+ ip=net["ip"], gateway=net["gateway"],
381
+ nameserver=net["dns"], searchdomain=net["searchdomain"],
382
+ timezone=_read_meta(container_dir, "kento-tz"),
383
+ env=(env.splitlines()
384
+ if (env := _read_meta(container_dir, "kento-env")) else None),
385
+ port=_read_meta(container_dir, "kento-port"),
386
+ memory=int(mem_raw) if mem_raw else None,
387
+ cores=int(cores_raw) if cores_raw else None,
388
+ hookscript_ref=hookscript_ref,
389
+ unprivileged=unprivileged,
390
+ )
391
+
392
+ # Refresh the idmap range for the unprivileged hook from the freshly
393
+ # generated text (mirrors create.py:1129-1132).
394
+ if unprivileged:
395
+ base, count = _pve_idmap_range(pve_conf_text)
396
+ (container_dir / "kento-idmap-range").write_text(f"{base} {count}\n")
397
+
398
+ write_pve_config(vmid, pve_conf_text)
399
+ return vmid
400
+
401
+
402
+ def adopt(name: str, *, container_dir: Path | None = None,
403
+ mode: str | None = None) -> dict:
404
+ """Heal an orphaned PVE instance by regenerating its missing ``.conf``.
405
+
406
+ An orphan is a kento-managed pve-lxc / pve-vm instance whose state dir
407
+ survives but whose PVE config was destroyed out-of-band. ``adopt``
408
+ rebuilds the derived PVE artifacts (snippets wrapper + hook + ``.conf``)
409
+ from surviving state, bringing the instance back as a known instance. It
410
+ does NOT auto-start or re-mount the rootfs — run ``kento start`` after.
411
+
412
+ Returns ``{"name", "vmid", "mode"}`` for the caller to render.
413
+
414
+ Refuses (raising the appropriate KentoError subclass) when:
415
+ - the mode is not a PVE mode (ModeError);
416
+ - the instance is NOT an orphan — its PVE config already exists
417
+ (StateError);
418
+ - the vmid is now occupied by a *different* instance (either config
419
+ kind) — a collision (StateError);
420
+ - required network metadata is missing (a pre-1.6.0 instance whose
421
+ config is not faithfully recoverable) — fail closed (StateError).
422
+
423
+ Library code: silent (no print / sys.exit / stderr); raises KentoError.
424
+ """
425
+ require_root()
426
+
427
+ if container_dir is None or mode is None:
428
+ container_dir, mode = resolve_any(name)
429
+
430
+ if mode not in ("pve", "pve-vm"):
431
+ raise ModeError(
432
+ "adopt only applies to PVE instances (pve-lxc/pve-vm); "
433
+ f"'{name}' is a plain {mode} instance with no PVE config."
434
+ )
435
+
436
+ vmid = _adopt_vmid(container_dir, mode)
437
+
438
+ # Fail closed on un-recoverable network metadata. A pre-1.6.0 orphan may
439
+ # predate kento-net-type; we refuse rather than emit a config that differs
440
+ # from what create would have written. (No --flags fill path by design.)
441
+ # Pure read; done outside the lock so we don't widen the critical section.
442
+ if _read_meta(container_dir, "kento-net-type") is None:
443
+ raise StateError(
444
+ f"network config not recoverable from surviving state for "
445
+ f"'{name}' (instance predates 1.6.0 metadata); use "
446
+ f"'kento destroy -f {name}' to discard, or recreate."
447
+ )
448
+
449
+ # Hold the SAME lock create() holds across the check->write critical
450
+ # section: the orphan-check, the vmid-occupied scan, and emit_pve_config
451
+ # must be atomic w.r.t. a concurrent `kento create`. Without it, a create
452
+ # could write a fresh config at this vmid between our check and our write,
453
+ # and adopt would clobber it (TOCTOU). Scope mirrors create's: only the
454
+ # check+validate+emit region — name resolution / metadata reads stay out.
455
+ with kento_lock():
456
+ # Not an orphan: its PVE config already exists -> nothing to adopt.
457
+ # An indeterminate probe (PermissionError/OSError — e.g. /etc/pve
458
+ # unreadable) must NEVER act: refuse cleanly as a KentoError rather
459
+ # than let a raw OSError escape (matches find_orphans/_is_orphan,
460
+ # which treat indeterminate as "not classified").
461
+ try:
462
+ already_exists = pve_config_exists(str(vmid), mode)
463
+ except (PermissionError, OSError) as e:
464
+ raise StateError(
465
+ f"cannot determine whether '{name}' (vmid {vmid}) is an "
466
+ f"orphan: {e}. Re-run as root or retry when /etc/pve is "
467
+ f"accessible."
468
+ ) from e
469
+ if already_exists:
470
+ raise StateError(
471
+ f"instance '{name}' is not an orphan; its PVE config exists "
472
+ f"(vmid {vmid}). Nothing to adopt."
473
+ )
474
+
475
+ # vmid now occupied by a DIFFERENT instance in EITHER config namespace
476
+ # (a pve-vm may have taken an orphaned pve-lxc's vmid, or vice versa).
477
+ # Both probes above/here are for THIS vmid; since this instance's own
478
+ # config is gone (checked above), any hit here is a foreign occupant.
479
+ for other_mode in ("pve", "pve-vm"):
480
+ try:
481
+ occupied = pve_config_exists(str(vmid), other_mode)
482
+ except (PermissionError, OSError) as e:
483
+ raise StateError(
484
+ f"cannot determine vmid {vmid} occupancy: {e}. Retry "
485
+ f"when /etc/pve is accessible."
486
+ ) from e
487
+ if occupied:
488
+ raise StateError(
489
+ f"cannot adopt '{name}': vmid {vmid} is already occupied "
490
+ f"by another PVE instance. Resolve the collision first "
491
+ f"(e.g. 'kento destroy -f {name}' to discard this orphan)."
492
+ )
493
+
494
+ emit_pve_config(container_dir, mode)
495
+
496
+ display = _read_meta(container_dir, "kento-name") or container_dir.name
497
+ logger.info("Adopted: %s (vmid %s)", display, vmid)
498
+ return {"name": display, "vmid": vmid, "mode": mode}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kento-core
3
- Version: 1.6.0.dev1
3
+ Version: 1.6.0.dev2
4
4
  Summary: Kento core library — compose OCI images into LXC/VM system containers (importable engine)
5
5
  License-Expression: GPL-3.0-only
6
6
  Requires-Python: >=3.11
@@ -22,6 +22,7 @@ src/kento/locking.py
22
22
  src/kento/logs.py
23
23
  src/kento/lxc_hook.py
24
24
  src/kento/pve.py
25
+ src/kento/reconcile.py
25
26
  src/kento/reset.py
26
27
  src/kento/set_cmd.py
27
28
  src/kento/start.py
@@ -34,6 +35,7 @@ src/kento_core.egg-info/PKG-INFO
34
35
  src/kento_core.egg-info/SOURCES.txt
35
36
  src/kento_core.egg-info/dependency_links.txt
36
37
  src/kento_core.egg-info/top_level.txt
38
+ tests/test_adopt.py
37
39
  tests/test_attach.py
38
40
  tests/test_cloudinit.py
39
41
  tests/test_create.py
@@ -56,6 +58,7 @@ tests/test_locking.py
56
58
  tests/test_logs.py
57
59
  tests/test_lxc_hook.py
58
60
  tests/test_pve.py
61
+ tests/test_reconcile.py
59
62
  tests/test_reset.py
60
63
  tests/test_run_or_die_integration.py
61
64
  tests/test_set.py