kento-core 1.6.0.dev1__tar.gz → 1.6.0.dev3__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 (75) hide show
  1. {kento_core-1.6.0.dev1/src/kento_core.egg-info → kento_core-1.6.0.dev3}/PKG-INFO +1 -1
  2. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/pyproject.toml +1 -1
  3. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/src/kento/__init__.py +11 -5
  4. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/src/kento/create.py +33 -5
  5. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/src/kento/defaults.py +55 -1
  6. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/src/kento/diagnose.py +81 -12
  7. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/src/kento/hook.py +14 -1
  8. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/src/kento/hook.sh +29 -13
  9. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/src/kento/images.py +54 -7
  10. kento_core-1.6.0.dev3/src/kento/layers.py +293 -0
  11. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/src/kento/list.py +13 -3
  12. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/src/kento/pve.py +27 -3
  13. kento_core-1.6.0.dev3/src/kento/reconcile.py +498 -0
  14. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/src/kento/reset.py +14 -4
  15. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/src/kento/set_cmd.py +1 -1
  16. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/src/kento/vm.py +14 -2
  17. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/src/kento/vm_hook.py +37 -16
  18. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3/src/kento_core.egg-info}/PKG-INFO +1 -1
  19. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/src/kento_core.egg-info/SOURCES.txt +3 -0
  20. kento_core-1.6.0.dev3/tests/test_adopt.py +348 -0
  21. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/tests/test_create.py +67 -5
  22. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/tests/test_defaults.py +1 -1
  23. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/tests/test_diagnose.py +95 -1
  24. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/tests/test_hook.py +46 -0
  25. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/tests/test_image_hold.py +26 -5
  26. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/tests/test_images.py +16 -6
  27. kento_core-1.6.0.dev3/tests/test_layers.py +356 -0
  28. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/tests/test_pve.py +66 -8
  29. kento_core-1.6.0.dev3/tests/test_reconcile.py +443 -0
  30. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/tests/test_reset.py +38 -15
  31. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/tests/test_validate_name.py +13 -5
  32. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/tests/test_vm.py +6 -6
  33. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/tests/test_vm_hook.py +33 -0
  34. kento_core-1.6.0.dev1/src/kento/layers.py +0 -81
  35. kento_core-1.6.0.dev1/tests/test_layers.py +0 -136
  36. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/LICENSE.md +0 -0
  37. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/README.md +0 -0
  38. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/setup.cfg +0 -0
  39. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/src/kento/attach.py +0 -0
  40. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/src/kento/cloudinit.py +0 -0
  41. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/src/kento/destroy.py +0 -0
  42. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/src/kento/errors.py +0 -0
  43. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/src/kento/exec_cmd.py +0 -0
  44. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/src/kento/info.py +0 -0
  45. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/src/kento/inject.py +0 -0
  46. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/src/kento/inject.sh +0 -0
  47. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/src/kento/locking.py +0 -0
  48. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/src/kento/logs.py +0 -0
  49. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/src/kento/lxc_hook.py +0 -0
  50. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/src/kento/start.py +0 -0
  51. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/src/kento/stop.py +0 -0
  52. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/src/kento/subprocess_util.py +0 -0
  53. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/src/kento/suspend.py +0 -0
  54. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/src/kento_core.egg-info/dependency_links.txt +0 -0
  55. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/src/kento_core.egg-info/top_level.txt +0 -0
  56. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/tests/test_attach.py +0 -0
  57. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/tests/test_cloudinit.py +0 -0
  58. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/tests/test_create_locking.py +0 -0
  59. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/tests/test_create_passthrough.py +0 -0
  60. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/tests/test_destroy.py +0 -0
  61. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/tests/test_errors.py +0 -0
  62. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/tests/test_exec.py +0 -0
  63. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/tests/test_info.py +0 -0
  64. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/tests/test_init.py +0 -0
  65. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/tests/test_inject.py +0 -0
  66. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/tests/test_list.py +0 -0
  67. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/tests/test_locking.py +0 -0
  68. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/tests/test_logs.py +0 -0
  69. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/tests/test_lxc_hook.py +0 -0
  70. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/tests/test_run_or_die_integration.py +0 -0
  71. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/tests/test_set.py +0 -0
  72. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/tests/test_start.py +0 -0
  73. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/tests/test_stop.py +0 -0
  74. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/tests/test_subprocess_util.py +0 -0
  75. {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/tests/test_suspend.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.dev3
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.dev3"
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"
@@ -24,31 +24,37 @@ LXC_BASE = Path("/var/lib/lxc")
24
24
  VM_BASE = Path("/var/lib/kento/vm")
25
25
 
26
26
  _NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]*$")
27
- _NAME_MAX_LEN = 63
28
27
 
29
28
 
30
29
  def validate_name(name: str, *, what: str = "instance name") -> None:
31
30
  """Reject names that would enable injection or path traversal.
32
31
 
33
32
  Accepts: ASCII alphanumerics plus `_`, `.`, `-`. Must start with
34
- alphanumeric. Max 63 chars (matches Linux HOST_NAME_MAX constraint).
33
+ alphanumeric. Max MAX_INSTANCE_NAME (64) chars -- the name becomes the
34
+ guest hostname (HOST_NAME_MAX) and is the last otherwise-uncapped
35
+ contributor to the overlay mount-options budget (see layers.py).
35
36
  Rejects: empty, whitespace, shell metacharacters, `/`, `..`, NUL.
36
37
 
37
38
  Raises ValidationError on rejection. what is used in the message for
38
39
  context (e.g. "instance name", "auto-generated name").
39
40
  """
41
+ from kento.defaults import MAX_INSTANCE_NAME
42
+
40
43
  if not isinstance(name, str) or not name:
41
44
  raise ValidationError(f"{what} cannot be empty")
42
- if len(name) > _NAME_MAX_LEN:
45
+ if len(name) > MAX_INSTANCE_NAME:
43
46
  raise ValidationError(
44
- f"{what} too long ({len(name)} chars, max {_NAME_MAX_LEN}): {name!r}"
47
+ f"{what} {name!r} is {len(name)} characters; the maximum is "
48
+ f"{MAX_INSTANCE_NAME} (it becomes the guest hostname and bounds "
49
+ f"the overlay mount options)."
45
50
  )
46
51
  if "\x00" in name:
47
52
  raise ValidationError(f"{what} contains NUL byte: {name!r}")
48
53
  if not _NAME_RE.match(name):
49
54
  raise ValidationError(
50
55
  f"invalid {what}: {name!r}. Names must start with a letter "
51
- f"or digit and contain only [A-Za-z0-9_.-] (max {_NAME_MAX_LEN} chars)."
56
+ f"or digit and contain only [A-Za-z0-9_.-] "
57
+ f"(max {MAX_INSTANCE_NAME} chars)."
52
58
  )
53
59
 
54
60
 
@@ -9,14 +9,14 @@ from pathlib import Path
9
9
  from kento import (LXC_BASE, VM_BASE, _scan_namespace, next_instance_name,
10
10
  require_root, sanitize_image_name, upper_base, validate_name)
11
11
  from kento.cloudinit import detect_cloudinit, write_seed
12
- from kento.defaults import (LXC_TTY, LXC_MOUNT_AUTO, LXC_MOUNT_AUTO_NESTING,
13
- LXC_ARG_DENYLIST, PVE_ARG_DENYLIST,
14
- QEMU_ARG_DENYLIST)
12
+ from kento.defaults import (APPARMOR_SYSTEMD_RULES, LXC_TTY, LXC_MOUNT_AUTO,
13
+ LXC_MOUNT_AUTO_NESTING, LXC_ARG_DENYLIST,
14
+ PVE_ARG_DENYLIST, QEMU_ARG_DENYLIST)
15
15
  from kento.errors import (InstanceExistsError, ModeError, StateError,
16
16
  SubprocessError, ValidationError)
17
17
  from kento.hook import write_hook
18
18
  from kento.inject import write_inject
19
- from kento.layers import resolve_layers
19
+ from kento.layers import resolve_image_id, resolve_layers
20
20
  from kento.locking import kento_lock
21
21
 
22
22
  logger = logging.getLogger("kento")
@@ -384,8 +384,14 @@ def generate_config(name: str, lxc_dir: Path, *, bridge: str | None = None,
384
384
  " enforce the host/container boundary; in-kernel MAC confinement off)."
385
385
  )
386
386
  lines.append(f"lxc.apparmor.profile = {profile}")
387
- lines.append("lxc.apparmor.allow_nesting = 1")
388
387
  lines.append("lxc.apparmor.allow_incomplete = 1")
388
+ # Narrow AppArmor grant for modern systemd (replaces the old broad
389
+ # allow_nesting=1). Only valid with a generated profile, and only needed
390
+ # when nesting is off -- with --allow-nesting, nesting.conf already sets
391
+ # allow_nesting=1. The unconfined escape hatch needs nothing (permits all).
392
+ if profile == "generated" and not nesting:
393
+ for rule in APPARMOR_SYSTEMD_RULES:
394
+ lines.append(f"lxc.apparmor.raw = {rule}")
389
395
  # Unprivileged plain-LXC: map container UID/GID 0 onto an unprivileged
390
396
  # host range (100000:65536). The actual rootfs idmap is done per-layer
391
397
  # by kento's hook (kento-hook): for each lowerdir, the hook creates an
@@ -706,6 +712,15 @@ def create(image: str, *, name: str | None = None, bridge: str | None = None,
706
712
  # is needed here.
707
713
  layers = resolve_layers(image)
708
714
 
715
+ # Fail closed on an over-deep overlay BEFORE any filesystem side effect.
716
+ # The kernel caps classic mount(2) options at one 4096-byte page; an image
717
+ # with more than MAX_OVERLAY_LAYERS layers would overrun it and be silently
718
+ # truncated, failing the overlay mount with cryptic errors. The layer-count
719
+ # cap is checked here (zero side effects); the exact-byte backstop runs once
720
+ # state_dir is known (below). See layers.preflight_overlay_layers.
721
+ from kento.layers import preflight_overlay_layers
722
+ preflight_overlay_layers(layers)
723
+
709
724
  # detect_cloudinit() does filesystem I/O over every layer. ``layers`` is
710
725
  # resolved once and never reassigned, so probe once and reuse the boolean
711
726
  # at all three decision sites below (cloudinit-mode precondition, the
@@ -837,8 +852,21 @@ def create(image: str, *, name: str | None = None, bridge: str | None = None,
837
852
  (state_dir / "upper").mkdir(exist_ok=True)
838
853
  (state_dir / "work").mkdir(exist_ok=True)
839
854
 
855
+ # Byte backstop: now that state_dir (hence upper/work paths) is known,
856
+ # assert the exact overlay options string can't truncate. Runs before
857
+ # any hook/config/PVE state is generated; a violation rolls back via
858
+ # the undos registered above. See preflight_overlay_layers.
859
+ preflight_overlay_layers(layers, state_dir)
860
+
840
861
  # Write image reference, layer paths, state dir, mode, and name
841
862
  (container_dir / "kento-image").write_text(image + "\n")
863
+ # Record the resolved content-ID alongside the (floating) tag so a
864
+ # later scrub/diagnose can detect when the tag has moved to a new
865
+ # image. Skip if it can't be resolved (older podman) — drift detection
866
+ # simply degrades to a no-op for that guest.
867
+ _img_id = resolve_image_id(image)
868
+ if _img_id:
869
+ (container_dir / "kento-image-id").write_text(_img_id + "\n")
842
870
  (container_dir / "kento-layers").write_text(layers + "\n")
843
871
  (container_dir / "kento-state").write_text(str(state_dir) + "\n")
844
872
  (container_dir / "kento-mode").write_text(mode + "\n")
@@ -2,14 +2,68 @@
2
2
 
3
3
  from pathlib import Path
4
4
 
5
+ # --- Overlay layer cap ---
6
+ # Docker overlay2 parity. The kernel caps classic mount(2) options at one
7
+ # 4096-byte page; an over-deep lowerdir overruns it and the kernel SILENTLY
8
+ # TRUNCATES, corrupting upperdir/workdir and failing the overlay mount. kento
9
+ # mounts from short l/<short> symlinks + chdir (like Docker/podman) to stay
10
+ # small, and fails closed at create above this many layers rather than letting
11
+ # the kernel truncate. Lift once the new mount API (fsconfig, what
12
+ # LIBMOUNT_FORCE_MOUNT2 targets) is reliably available — it has no single-page
13
+ # limit (currently a silent no-op on some util-linux/kernel combos).
14
+ MAX_OVERLAY_LAYERS = 128
15
+
16
+ # The single-page mount(2) options limit. We compute the exact options string
17
+ # kento will emit and refuse to hand the kernel anything that could truncate,
18
+ # leaving a 16-byte safety margin. With the l/<short> short-link form the 128
19
+ # cap keeps us far under this; the byte check is a backstop for pathological
20
+ # state-dir / name lengths.
21
+ OVERLAY_OPTS_PAGE_LIMIT = 4096
22
+
23
+ # Maximum instance-name length. The name flows into STATE_DIR (e.g.
24
+ # /var/lib/kento/vm/<name>), which is the upperdir/workdir of the overlay
25
+ # mount-options string -- the last otherwise-uncapped contributor to that
26
+ # budget (the layer count + byte backstop above cap the rest). It also becomes
27
+ # the guest hostname, so HOST_NAME_MAX (64) is the natural ceiling.
28
+ MAX_INSTANCE_NAME = 64 # HOST_NAME_MAX; the name becomes the guest hostname, and
29
+ # bounds the overlay mount-options budget (see layers.py)
30
+
5
31
  # --- LXC defaults ---
6
32
  LXC_TTY = 2
7
33
  LXC_MOUNT_AUTO = "proc:mixed sys:mixed cgroup:mixed"
8
34
  LXC_MOUNT_AUTO_NESTING = "proc:rw sys:rw cgroup:rw"
9
35
  LXC_NESTING = False
10
36
 
37
+ # AppArmor rules that let modern systemd (256+) boot inside an LXC guest. systemd
38
+ # sandboxes its own core units (networkd, resolved, logind, journald, ...) with
39
+ # PrivateUsers=/PrivateMounts=, which create a user namespace and do bind/move/
40
+ # remount/pivot_root mounts. AppArmor 4.x (Debian 13 / kernel 6.x) mediates
41
+ # userns_create and these mounts; the LXC-generated profile denies them by default,
42
+ # so the guest comes up network-dead. These rules grant exactly that bounded
43
+ # sandboxing vocabulary -- narrower than allow_nesting (no nested-container peer
44
+ # rules, no raw proc/sys). Injected via lxc.apparmor.raw, which is only honored with
45
+ # a generated profile. Validated clean on droste-loom + kanibako-lxc (Debian 13,
46
+ # systemd 257) on a real PVE host.
47
+ APPARMOR_SYSTEMD_RULES = ("userns,", "mount,", "umount,", "pivot_root,", "mqueue,")
48
+
49
+ # --- PVE-LXC defaults ---
50
+ # PVE has no "unlimited" memory sentinel: it rejects `memory: 0` and
51
+ # `memory: max` at schema validation, and it silently backfills its 512 MiB
52
+ # schema default whenever the `memory:` field is omitted -- then enforces that
53
+ # 512 MiB host-side on the container cgroup (confirmed live on PVE 9.1.6: an
54
+ # empty conf yields memory.max = 536870912). So an omitted field is NOT
55
+ # unlimited on pve-lxc; it is a surprise 512 MiB cap (real-world OOM-kills).
56
+ # This value is PVE's schema ceiling (2^44 - 1 MiB, "signed int max"); it is
57
+ # accepted by pct, and the resulting byte value exceeds the cgroup's
58
+ # representable maximum so the kernel clamps memory.max to the literal string
59
+ # `max` -- i.e. truly unlimited. Chosen over detecting host RAM deliberately,
60
+ # so the value survives PVE cluster live-migration / extreme hosts (a host-RAM
61
+ # value baked in at create time could under-cap after the container migrates
62
+ # to a smaller node).
63
+ PVE_LXC_UNLIMITED_MEMORY_MB = 17592186044415
64
+
11
65
  # --- VM defaults ---
12
- VM_MEMORY = 512 # MB
66
+ VM_MEMORY = 1024 # MB
13
67
  VM_CORES = 1
14
68
  VM_KVM = True
15
69
  VM_MACHINE = "q35"
@@ -26,9 +26,10 @@ from kento import (LXC_BASE, VM_BASE, InstanceNotFoundError, _scan_namespace,
26
26
  is_running, pve_config_exists, read_mode, validate_name)
27
27
  from kento.cloudinit import detect_cloudinit
28
28
  from kento.create import _apparmor_active, _apparmor_parser_present
29
- from kento.images import _guest_names, _holds
29
+ from kento.images import _guest_names, _hold_image_ids, _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
 
@@ -164,6 +165,72 @@ def _check_stale_holds():
164
165
  return findings
165
166
 
166
167
 
168
+ def _short_id(image_id):
169
+ """First 12 hex chars of an image id, stripping any ``sha256:`` prefix."""
170
+ s = image_id.strip()
171
+ if s.startswith("sha256:"):
172
+ s = s[len("sha256:"):]
173
+ return s[:12]
174
+
175
+
176
+ def _check_hold_drift():
177
+ """Image-hold / guest image-ID drift (host). READ ONLY.
178
+
179
+ For each guest that exists AND has a hold, compare the hold's pinned
180
+ content-ID (its ``io.kento.hold-image-id`` label) against the guest's
181
+ recorded ``kento-image-id`` file. A mismatch means the image tag moved
182
+ since the hold was created (e.g. a re-pull) and a scrub is needed to
183
+ re-pin. Legacy guests/holds missing either value are skipped silently
184
+ (no noise). Never mutates.
185
+ """
186
+ try:
187
+ guests = _guest_names()
188
+ hold_ids = _hold_image_ids()
189
+ except (PermissionError, OSError):
190
+ return [_finding("hold", "info", "host",
191
+ "could not enumerate image-hold pins (podman/root?)",
192
+ None)]
193
+
194
+ # Map guest display name -> its recorded kento-image-id (when present).
195
+ guest_image_ids: dict[str, str] = {}
196
+ for base in (LXC_BASE, VM_BASE):
197
+ if not base.is_dir():
198
+ continue
199
+ for image_file in base.glob("*/kento-image"):
200
+ container_dir = image_file.parent
201
+ try:
202
+ name_file = container_dir / "kento-name"
203
+ gname = (name_file.read_text().strip()
204
+ if name_file.is_file() else container_dir.name)
205
+ id_file = container_dir / "kento-image-id"
206
+ if id_file.is_file():
207
+ guest_image_ids[gname] = id_file.read_text().strip()
208
+ except (PermissionError, OSError):
209
+ continue
210
+
211
+ findings = []
212
+ drift = 0
213
+ for n in sorted(guests):
214
+ pinned = hold_ids.get(n, "")
215
+ guest_id = guest_image_ids.get(n, "")
216
+ if not pinned or not guest_id:
217
+ # Legacy / pre-fix guest or hold (missing label or file) — skip.
218
+ continue
219
+ if pinned != guest_id:
220
+ drift += 1
221
+ findings.append(_finding(
222
+ "hold", "warn", "host",
223
+ f"image hold for '{n}' pins {_short_id(pinned)} but guest "
224
+ f"runs {_short_id(guest_id)} — run 'kento scrub {n}' to "
225
+ f"re-pin",
226
+ f"kento scrub {n}"))
227
+ if drift == 0:
228
+ findings.append(_finding(
229
+ "hold", "ok", "host",
230
+ "image holds are pinned to their guests' current images", None))
231
+ return findings
232
+
233
+
167
234
  def _check_vmid_health():
168
235
  """VMID allocation health (host, pve only).
169
236
 
@@ -204,7 +271,8 @@ def _check_vmid_health():
204
271
  f"{len(reserved_orphans)} recorded vmid(s) are reserved by "
205
272
  f"orphaned kento state and will not be reassigned: "
206
273
  f"{', '.join(str(v) for v in reserved_orphans)}",
207
- "kento destroy -f <name> for the orphan(s), then re-check")]
274
+ "kento adopt <name> to heal, or kento destroy -f <name> to "
275
+ "discard, the orphan(s), then re-check")]
208
276
  return [_finding("vmid", "info", "host",
209
277
  f"next free vmid is {nxt}; "
210
278
  f"{len(recorded)} vmid(s) recorded by kento", None)]
@@ -216,18 +284,17 @@ def _check_vmid_health():
216
284
  def _check_orphan(container_dir, mode, display):
217
285
  """Orphan check (pve modes): state present but PVE .conf gone.
218
286
 
219
- Same logic list.list_containers uses: pve-lxc uses the dir name as the
220
- vmid; pve-vm reads kento-vmid.
287
+ Detection is shared with list.list_containers via reconcile._is_orphan
288
+ (pve-lxc uses the dir name as the vmid; pve-vm reads kento-vmid). The
289
+ diagnose-bound pve_config_exists is passed in so the predicate probes
290
+ through the same name diagnose's tests patch.
221
291
  """
222
292
  if mode not in ("pve", "pve-vm"):
223
293
  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):
294
+ check_vmid = _orphan_vmid(container_dir, mode)
295
+ gone = _is_orphan(container_dir, mode, pve_config_exists)
296
+ if gone is None:
297
+ # Indeterminate config probe (PermissionError/OSError).
231
298
  return [_finding("orphan", "info", display,
232
299
  "could not check PVE config (needs root?)", None)]
233
300
  if gone:
@@ -235,7 +302,8 @@ def _check_orphan(container_dir, mode, display):
235
302
  "orphan", "warn", display,
236
303
  f"instance '{display}' has kento state but its PVE config "
237
304
  f"(vmid {check_vmid or '?'}) is gone — orphaned",
238
- f"kento destroy -f {display}")]
305
+ f"kento adopt {display} to heal it, or "
306
+ f"kento destroy -f {display} to discard it")]
239
307
  return [_finding("orphan", "ok", display,
240
308
  f"PVE config present (vmid {check_vmid})", None)]
241
309
 
@@ -456,6 +524,7 @@ def run_diagnostics(name=None):
456
524
  # Host-level checks run regardless of name.
457
525
  checks.extend(_check_apparmor())
458
526
  checks.extend(_check_stale_holds())
527
+ checks.extend(_check_hold_drift())
459
528
  checks.extend(_check_vmid_health())
460
529
 
461
530
  for container_dir, mode, display in instances:
@@ -10,13 +10,26 @@ def generate_hook(container_dir: Path, layers: str, name: str,
10
10
  """Return a hook script with baked-in paths for a container.
11
11
 
12
12
  state_dir is where upper/work live. Defaults to container_dir if not given.
13
+
14
+ LAYERS is baked in chdir-relative ``l/<short>`` form (Docker/podman
15
+ parity) so a deeply layered image's overlay mount(2) options stay under
16
+ the kernel's 4096-byte page limit. OVERLAY_BASE is the podman overlay
17
+ store root; the pre-start mount cd's into it inside a subshell so the
18
+ chdir never leaks to inject.sh / later steps. If the short form can't be
19
+ derived, to_overlay_lowerdir falls back to absolute layers and
20
+ OVERLAY_BASE is "/" (a harmless no-op cd).
13
21
  """
22
+ from kento.layers import to_overlay_lowerdir
14
23
  sd = state_dir or container_dir
24
+ overlay_base, rel_layers = to_overlay_lowerdir(str(layers))
25
+ if not overlay_base:
26
+ overlay_base = "/"
15
27
  return (_TEMPLATE
16
28
  .replace("@@NAME@@", str(name))
17
29
  .replace("@@CONTAINER_DIR@@", str(container_dir))
18
30
  .replace("@@STATE_DIR@@", str(sd))
19
- .replace("@@LAYERS@@", str(layers)))
31
+ .replace("@@OVERLAY_BASE@@", overlay_base)
32
+ .replace("@@LAYERS@@", rel_layers))
20
33
 
21
34
 
22
35
  def write_hook(container_dir: Path, layers: str, name: str,
@@ -4,6 +4,7 @@ set -eu
4
4
  NAME="@@NAME@@"
5
5
  CONTAINER_DIR="@@CONTAINER_DIR@@"
6
6
  STATE_DIR="@@STATE_DIR@@"
7
+ OVERLAY_BASE="@@OVERLAY_BASE@@"
7
8
  LAYERS="@@LAYERS@@"
8
9
  HOOK_TYPE="${LXC_HOOK_TYPE:-$3}"
9
10
 
@@ -221,17 +222,6 @@ WORKER_EOF
221
222
 
222
223
  case "$HOOK_TYPE" in
223
224
  pre-start|pre-mount|mount)
224
- # Validate layer paths still exist (image may have changed)
225
- IFS=:
226
- for dir in $LAYERS; do
227
- if [ ! -d "$dir" ]; then
228
- echo "kento-hook: error: layer path missing: $dir" >&2
229
- echo "kento-hook: image may have changed. Run: kento scrub $NAME" >&2
230
- exit 1
231
- fi
232
- done
233
- unset IFS
234
-
235
225
  # pre-mount (PVE privileged): mount at lxc.rootfs.path source so LXC picks it up
236
226
  # pre-start (PVE unprivileged): the only hook in the host INITIAL ns as
237
227
  # real root — pre-mount/mount run in the child userns where idmapped
@@ -243,6 +233,28 @@ case "$HOOK_TYPE" in
243
233
  mkdir -p "$STATE_DIR/upper" "$STATE_DIR/work" "$ROOTFS"
244
234
  export LIBMOUNT_FORCE_MOUNT2=always
245
235
 
236
+ # Validate layers + mount inside a SUBSHELL that cd's into the podman
237
+ # overlay store root. LAYERS is in chdir-relative l/<short> form
238
+ # (Docker/podman parity) so the mount(2) options stay under the
239
+ # kernel's 4096-byte page limit. The subshell scopes the chdir so it
240
+ # never leaks to inject.sh / later hook phases (which use absolute
241
+ # paths). Both the privileged and unprivileged paths resolve the
242
+ # relative $LAYERS against this cwd; idmap targets / upper / work stay
243
+ # absolute and so are unaffected by the cwd.
244
+ (
245
+ cd "$OVERLAY_BASE" || { echo "kento-hook: error: overlay base missing: $OVERLAY_BASE" >&2; exit 1; }
246
+
247
+ # Validate layer paths still exist (image may have changed)
248
+ IFS=:
249
+ for dir in $LAYERS; do
250
+ if [ ! -d "$dir" ]; then
251
+ echo "kento-hook: error: layer path missing: $dir" >&2
252
+ echo "kento-hook: image may have changed. Run: kento scrub $NAME" >&2
253
+ exit 1
254
+ fi
255
+ done
256
+ unset IFS
257
+
246
258
  if [ -f "$CONTAINER_DIR/kento-unprivileged" ]; then
247
259
  # ---------------------------------------------------------------------------
248
260
  # Unprivileged (per-layer idmap) path — mainline kernel 5.19+, util-linux 2.40+
@@ -354,13 +366,17 @@ case "$HOOK_TYPE" in
354
366
  "$ROOTFS"
355
367
  fi
356
368
  else
357
- # Privileged path unchanged.
369
+ # Privileged path. lowerdir is the relative l/<short> form,
370
+ # resolved against the cwd ($OVERLAY_BASE) set above.
358
371
  mount -t overlay overlay \
359
372
  -o "lowerdir=$LAYERS,upperdir=$STATE_DIR/upper,workdir=$STATE_DIR/work" \
360
373
  "$ROOTFS"
361
374
  fi
375
+ ) || { echo "kento-hook: error: overlay mount failed" >&2; exit 1; }
362
376
 
363
- # Guest config injection — shared with VM / PVE-VM modes.
377
+ # Guest config injection — shared with VM / PVE-VM modes. Runs with the
378
+ # ORIGINAL cwd (the mount subshell's chdir did not leak); $ROOTFS and
379
+ # $CONTAINER_DIR are absolute.
364
380
  sh "$CONTAINER_DIR/kento-inject.sh" "$ROOTFS" "$CONTAINER_DIR"
365
381
  ;;
366
382
  start-host)
@@ -97,6 +97,36 @@ def _holds() -> list[tuple[str, str]]:
97
97
  return holds
98
98
 
99
99
 
100
+ def _hold_image_ids() -> dict[str, str]:
101
+ """Map each held-for guest name -> the hold's pinned content-ID label.
102
+
103
+ Separate query from ``_holds()`` (whose (name, image) shape prune/list
104
+ depend on) so those callers are untouched. Only entries carrying a
105
+ non-empty ``io.kento.hold-image-id`` label are returned; legacy holds
106
+ created before the label existed are simply absent from the map.
107
+ """
108
+ result = subprocess.run(
109
+ [*_podman_cmd(), "ps", "-a",
110
+ "--filter", "label=io.kento.hold-for",
111
+ "--format",
112
+ '{{.Label "io.kento.hold-for"}}\t{{.Label "io.kento.hold-image-id"}}'],
113
+ capture_output=True, text=True,
114
+ )
115
+ ids: dict[str, str] = {}
116
+ if result.returncode != 0:
117
+ return ids
118
+ for line in result.stdout.splitlines():
119
+ line = line.rstrip("\n")
120
+ if not line.strip():
121
+ continue
122
+ parts = line.split("\t")
123
+ held_for = parts[0].strip()
124
+ image_id = parts[1].strip() if len(parts) > 1 else ""
125
+ if held_for and image_id and image_id != "<no value>":
126
+ ids[held_for] = image_id
127
+ return ids
128
+
129
+
100
130
  def list_images(in_use_only: bool = False) -> str:
101
131
  """List kento-managed images (read-only).
102
132
 
@@ -142,7 +172,7 @@ def list_images(in_use_only: bool = False) -> str:
142
172
  return "\n".join(lines)
143
173
 
144
174
 
145
- def prune(yes: bool = False) -> str:
175
+ def prune(yes: bool = False) -> tuple[str, int]:
146
176
  """Safe GC of orphaned kento hold containers and the images they freed.
147
177
 
148
178
  DRY-RUN by default. Removes only holds whose guest no longer exists,
@@ -150,7 +180,13 @@ def prune(yes: bool = False) -> str:
150
180
  no surviving guest. Never removes a hold whose guest exists; never
151
181
  prunes all images.
152
182
 
153
- Returns the user-facing plan/summary text as a string (no trailing newline).
183
+ Returns ``(summary_text, failed_count)`` where ``summary_text`` is the
184
+ user-facing plan/summary (no trailing newline) and ``failed_count`` is
185
+ the number of candidate images podman refused to remove. Because every
186
+ candidate is, by construction, an image with no surviving guest ref and
187
+ no surviving-hold ref, a removal failure is meaningful (an external
188
+ non-kento reference or a kento-accounting miss) — the CLI exits
189
+ non-zero on a non-zero count, mirroring ``diagnose``.
154
190
  """
155
191
  guest_names = _guest_names()
156
192
  refs = _guest_image_refs() # image -> guests still present
@@ -162,7 +198,7 @@ def prune(yes: bool = False) -> str:
162
198
  if name in guest_names]
163
199
 
164
200
  if not orphaned:
165
- return "Nothing to prune."
201
+ return "Nothing to prune.", 0
166
202
 
167
203
  # Images still pinned by a surviving (non-orphaned) hold must not be
168
204
  # removed. Likewise, images referenced by any guest must not be removed.
@@ -186,7 +222,7 @@ def prune(yes: bool = False) -> str:
186
222
  else:
187
223
  lines.append(" Images then eligible for removal: (none)")
188
224
  lines.append("Run 'kento prune --yes' to remove them.")
189
- return "\n".join(lines)
225
+ return "\n".join(lines), 0
190
226
 
191
227
  removed_holds = 0
192
228
  for name, _image in orphaned:
@@ -194,6 +230,7 @@ def prune(yes: bool = False) -> str:
194
230
  removed_holds += 1
195
231
 
196
232
  removed_images = 0
233
+ failures: list[tuple[str, str]] = []
197
234
  for image in candidate_images:
198
235
  result = subprocess.run(
199
236
  [*_podman_cmd(), "image", "rm", image],
@@ -202,9 +239,19 @@ def prune(yes: bool = False) -> str:
202
239
  if result.returncode == 0:
203
240
  removed_images += 1
204
241
  else:
205
- # podman refuses if the image is still in use — that is the
206
- # intended safety net, so we tolerate the failure and report.
242
+ # Every candidate had no surviving guest ref and no surviving
243
+ # hold ref, so a refusal here is meaningful — an external
244
+ # non-kento reference or a kento-accounting miss. Surface it.
207
245
  msg = (result.stderr or result.stdout or "").strip()
208
246
  logger.warning("skipped image %s: %s", image, msg)
247
+ failures.append((image, msg))
209
248
 
210
- return f"Removed {removed_holds} orphaned hold(s), {removed_images} image(s)."
249
+ lines = [f"Removed {removed_holds} orphaned hold(s), {removed_images} image(s)."]
250
+ if failures:
251
+ lines.append(
252
+ f"Failed to remove {len(failures)} image(s) "
253
+ "(still referenced or accounting mismatch):"
254
+ )
255
+ for image, reason in failures:
256
+ lines.append(f" {image}: {reason}")
257
+ return "\n".join(lines), len(failures)