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.
- {kento_core-1.6.0.dev1/src/kento_core.egg-info → kento_core-1.6.0.dev3}/PKG-INFO +1 -1
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/pyproject.toml +1 -1
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/src/kento/__init__.py +11 -5
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/src/kento/create.py +33 -5
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/src/kento/defaults.py +55 -1
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/src/kento/diagnose.py +81 -12
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/src/kento/hook.py +14 -1
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/src/kento/hook.sh +29 -13
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/src/kento/images.py +54 -7
- kento_core-1.6.0.dev3/src/kento/layers.py +293 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/src/kento/list.py +13 -3
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/src/kento/pve.py +27 -3
- kento_core-1.6.0.dev3/src/kento/reconcile.py +498 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/src/kento/reset.py +14 -4
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/src/kento/set_cmd.py +1 -1
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/src/kento/vm.py +14 -2
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/src/kento/vm_hook.py +37 -16
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3/src/kento_core.egg-info}/PKG-INFO +1 -1
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/src/kento_core.egg-info/SOURCES.txt +3 -0
- kento_core-1.6.0.dev3/tests/test_adopt.py +348 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/tests/test_create.py +67 -5
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/tests/test_defaults.py +1 -1
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/tests/test_diagnose.py +95 -1
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/tests/test_hook.py +46 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/tests/test_image_hold.py +26 -5
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/tests/test_images.py +16 -6
- kento_core-1.6.0.dev3/tests/test_layers.py +356 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/tests/test_pve.py +66 -8
- kento_core-1.6.0.dev3/tests/test_reconcile.py +443 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/tests/test_reset.py +38 -15
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/tests/test_validate_name.py +13 -5
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/tests/test_vm.py +6 -6
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/tests/test_vm_hook.py +33 -0
- kento_core-1.6.0.dev1/src/kento/layers.py +0 -81
- kento_core-1.6.0.dev1/tests/test_layers.py +0 -136
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/LICENSE.md +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/README.md +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/setup.cfg +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/src/kento/attach.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/src/kento/cloudinit.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/src/kento/destroy.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/src/kento/errors.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/src/kento/exec_cmd.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/src/kento/info.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/src/kento/inject.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/src/kento/inject.sh +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/src/kento/locking.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/src/kento/logs.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/src/kento/lxc_hook.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/src/kento/start.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/src/kento/stop.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/src/kento/subprocess_util.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/src/kento/suspend.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/src/kento_core.egg-info/dependency_links.txt +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/src/kento_core.egg-info/top_level.txt +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/tests/test_attach.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/tests/test_cloudinit.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/tests/test_create_locking.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/tests/test_create_passthrough.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/tests/test_destroy.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/tests/test_errors.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/tests/test_exec.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/tests/test_info.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/tests/test_init.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/tests/test_inject.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/tests/test_list.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/tests/test_locking.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/tests/test_logs.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/tests/test_lxc_hook.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/tests/test_run_or_die_integration.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/tests/test_set.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/tests/test_start.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/tests/test_stop.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/tests/test_subprocess_util.py +0 -0
- {kento_core-1.6.0.dev1 → kento_core-1.6.0.dev3}/tests/test_suspend.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.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
|
|
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) >
|
|
45
|
+
if len(name) > MAX_INSTANCE_NAME:
|
|
43
46
|
raise ValidationError(
|
|
44
|
-
f"{what}
|
|
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_.-]
|
|
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,
|
|
13
|
-
|
|
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 =
|
|
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>
|
|
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
|
-
|
|
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
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
|
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("@@
|
|
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
|
|
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
|
|
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
|
-
#
|
|
206
|
-
#
|
|
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
|
-
|
|
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)
|