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.
- {kento_core-1.6.0.dev1/src/kento_core.egg-info → kento_core-1.6.0.dev2}/PKG-INFO +1 -1
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/pyproject.toml +1 -1
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/src/kento/diagnose.py +13 -11
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/src/kento/list.py +13 -3
- kento_core-1.6.0.dev2/src/kento/reconcile.py +498 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2/src/kento_core.egg-info}/PKG-INFO +1 -1
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/src/kento_core.egg-info/SOURCES.txt +3 -0
- kento_core-1.6.0.dev2/tests/test_adopt.py +348 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/tests/test_diagnose.py +8 -1
- kento_core-1.6.0.dev2/tests/test_reconcile.py +443 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/LICENSE.md +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/README.md +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/setup.cfg +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/src/kento/__init__.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/src/kento/attach.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/src/kento/cloudinit.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/src/kento/create.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/src/kento/defaults.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/src/kento/destroy.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/src/kento/errors.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/src/kento/exec_cmd.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/src/kento/hook.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/src/kento/hook.sh +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/src/kento/images.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/src/kento/info.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/src/kento/inject.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/src/kento/inject.sh +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/src/kento/layers.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/src/kento/locking.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/src/kento/logs.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/src/kento/lxc_hook.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/src/kento/pve.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/src/kento/reset.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/src/kento/set_cmd.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/src/kento/start.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/src/kento/stop.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/src/kento/subprocess_util.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/src/kento/suspend.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/src/kento/vm.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/src/kento/vm_hook.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/src/kento_core.egg-info/dependency_links.txt +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/src/kento_core.egg-info/top_level.txt +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/tests/test_attach.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/tests/test_cloudinit.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/tests/test_create.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/tests/test_create_locking.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/tests/test_create_passthrough.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/tests/test_defaults.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/tests/test_destroy.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/tests/test_errors.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/tests/test_exec.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/tests/test_hook.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/tests/test_image_hold.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/tests/test_images.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/tests/test_info.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/tests/test_init.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/tests/test_inject.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/tests/test_layers.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/tests/test_list.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/tests/test_locking.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/tests/test_logs.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/tests/test_lxc_hook.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/tests/test_pve.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/tests/test_reset.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/tests/test_run_or_die_integration.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/tests/test_set.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/tests/test_start.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/tests/test_stop.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/tests/test_subprocess_util.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/tests/test_suspend.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/tests/test_validate_name.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/tests/test_vm.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev2}/tests/test_vm_hook.py +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "kento-core"
|
|
7
|
-
version = "1.6.0.
|
|
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>
|
|
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
|
-
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
|
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
|
-
|
|
60
|
-
if
|
|
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}
|
|
@@ -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
|