kento-core 1.6.0.dev3__tar.gz → 1.6.3.dev0__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.dev3/src/kento_core.egg-info → kento_core-1.6.3.dev0}/PKG-INFO +1 -1
- {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/pyproject.toml +1 -1
- {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/src/kento/__init__.py +146 -1
- kento_core-1.6.3.dev0/src/kento/_diagnosis.py +384 -0
- kento_core-1.6.3.dev0/src/kento/_images.py +1467 -0
- kento_core-1.6.3.dev0/src/kento/_instances.py +3417 -0
- kento_core-1.6.3.dev0/src/kento/_network.py +433 -0
- kento_core-1.6.3.dev0/src/kento/_platform.py +187 -0
- kento_core-1.6.3.dev0/src/kento/_references.py +673 -0
- kento_core-1.6.3.dev0/src/kento/_storage.py +65 -0
- {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/src/kento/create.py +201 -36
- {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/src/kento/errors.py +13 -0
- kento_core-1.6.3.dev0/src/kento/exec_cmd.py +118 -0
- {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/src/kento/hook.sh +192 -71
- {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/src/kento/images.py +20 -57
- {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/src/kento/info.py +10 -1
- kento_core-1.6.3.dev0/src/kento/portfwd.py +393 -0
- {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/src/kento/pve.py +9 -4
- {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/src/kento/set_cmd.py +33 -10
- {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/src/kento/stop.py +14 -1
- {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/src/kento/suspend.py +32 -0
- {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/src/kento/vm.py +84 -29
- {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/src/kento/vm_hook.py +20 -1
- {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0/src/kento_core.egg-info}/PKG-INFO +1 -1
- {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/src/kento_core.egg-info/SOURCES.txt +20 -0
- {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/tests/test_create.py +264 -1
- {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/tests/test_create_locking.py +1 -1
- kento_core-1.6.3.dev0/tests/test_diagnose_module.py +210 -0
- kento_core-1.6.3.dev0/tests/test_diagnosis.py +552 -0
- kento_core-1.6.3.dev0/tests/test_exec.py +229 -0
- kento_core-1.6.3.dev0/tests/test_hold.py +263 -0
- {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/tests/test_hook.py +33 -17
- kento_core-1.6.3.dev0/tests/test_image_ops.py +745 -0
- kento_core-1.6.3.dev0/tests/test_image_record.py +498 -0
- {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/tests/test_images.py +15 -79
- kento_core-1.6.3.dev0/tests/test_images_types.py +560 -0
- {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/tests/test_init.py +22 -0
- kento_core-1.6.3.dev0/tests/test_instances.py +2792 -0
- kento_core-1.6.3.dev0/tests/test_network.py +387 -0
- kento_core-1.6.3.dev0/tests/test_platform.py +201 -0
- kento_core-1.6.3.dev0/tests/test_portfwd.py +193 -0
- {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/tests/test_pve.py +16 -0
- kento_core-1.6.3.dev0/tests/test_references.py +606 -0
- {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/tests/test_set.py +89 -2
- {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/tests/test_stop.py +20 -0
- kento_core-1.6.3.dev0/tests/test_storage.py +48 -0
- {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/tests/test_suspend.py +45 -1
- {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/tests/test_vm.py +92 -0
- {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/tests/test_vm_hook.py +45 -0
- kento_core-1.6.0.dev3/src/kento/exec_cmd.py +0 -50
- kento_core-1.6.0.dev3/tests/test_exec.py +0 -128
- {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/LICENSE.md +0 -0
- {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/README.md +0 -0
- {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/setup.cfg +0 -0
- {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/src/kento/attach.py +0 -0
- {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/src/kento/cloudinit.py +0 -0
- {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/src/kento/defaults.py +0 -0
- {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/src/kento/destroy.py +0 -0
- {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/src/kento/diagnose.py +0 -0
- {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/src/kento/hook.py +0 -0
- {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/src/kento/inject.py +0 -0
- {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/src/kento/inject.sh +0 -0
- {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/src/kento/layers.py +0 -0
- {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/src/kento/list.py +0 -0
- {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/src/kento/locking.py +0 -0
- {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/src/kento/logs.py +0 -0
- {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/src/kento/lxc_hook.py +0 -0
- {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/src/kento/reconcile.py +0 -0
- {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/src/kento/reset.py +0 -0
- {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/src/kento/start.py +0 -0
- {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/src/kento/subprocess_util.py +0 -0
- {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/src/kento_core.egg-info/dependency_links.txt +0 -0
- {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/src/kento_core.egg-info/top_level.txt +0 -0
- {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/tests/test_adopt.py +0 -0
- {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/tests/test_attach.py +0 -0
- {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/tests/test_cloudinit.py +0 -0
- {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/tests/test_create_passthrough.py +0 -0
- {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/tests/test_defaults.py +0 -0
- {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/tests/test_destroy.py +0 -0
- {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/tests/test_diagnose.py +0 -0
- {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/tests/test_errors.py +0 -0
- {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/tests/test_image_hold.py +0 -0
- {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/tests/test_info.py +0 -0
- {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/tests/test_inject.py +0 -0
- {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/tests/test_layers.py +0 -0
- {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/tests/test_list.py +0 -0
- {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/tests/test_locking.py +0 -0
- {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/tests/test_logs.py +0 -0
- {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/tests/test_lxc_hook.py +0 -0
- {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/tests/test_reconcile.py +0 -0
- {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/tests/test_reset.py +0 -0
- {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/tests/test_run_or_die_integration.py +0 -0
- {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/tests/test_start.py +0 -0
- {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/tests/test_subprocess_util.py +0 -0
- {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/tests/test_validate_name.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.
|
|
7
|
+
version = "1.6.3.dev0"
|
|
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"
|
|
@@ -17,8 +17,76 @@ logger = logging.getLogger("kento")
|
|
|
17
17
|
|
|
18
18
|
from kento.errors import ( # noqa: F401 (public re-export)
|
|
19
19
|
KentoError, ValidationError, InstanceNotFoundError, InstanceExistsError,
|
|
20
|
-
ImageNotFoundError, ModeError, StateError, SubprocessError,
|
|
20
|
+
ImageNotFoundError, ModeError, StateError, StopTimeout, SubprocessError,
|
|
21
21
|
)
|
|
22
|
+
from kento._references import ( # noqa: F401 (public re-export)
|
|
23
|
+
MalformedReference, Endpoint, Digest, SourceReference, OciReference,
|
|
24
|
+
)
|
|
25
|
+
from kento._network import ( # noqa: F401 (public re-export)
|
|
26
|
+
NetworkMode, ForwardProtocol, NetworkConnection,
|
|
27
|
+
HostBinding, GuestTarget, ForwardAddressNotImplemented,
|
|
28
|
+
parse_forward_spec, render_forward_spec, parse_forwards, parse_cidr,
|
|
29
|
+
)
|
|
30
|
+
from kento._diagnosis import ( # noqa: F401 (public re-export)
|
|
31
|
+
DiagnosisDomain, CheckLevel, PruneScope, Finding, Diagnosis, ReclaimReport,
|
|
32
|
+
)
|
|
33
|
+
from kento._platform import ( # noqa: F401 (public re-export)
|
|
34
|
+
PlatformMode, PlatformProfile, Status,
|
|
35
|
+
)
|
|
36
|
+
from kento._storage import ( # noqa: F401 (public re-export)
|
|
37
|
+
StorageMode,
|
|
38
|
+
)
|
|
39
|
+
from kento._images import ( # noqa: F401 (public re-export)
|
|
40
|
+
DiskFormat, Layer, Hold, ManagedStatus, ImageRecord, Image, LayeredImage,
|
|
41
|
+
OciImage, VolumeImage, CompositeImage,
|
|
42
|
+
)
|
|
43
|
+
from kento._instances import ( # noqa: F401 (public re-export)
|
|
44
|
+
Instance, SystemContainer, VirtualMachine,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
# Curated public surface. The source-reference value types are re-exported
|
|
48
|
+
# flat (canonical paths kento.OciReference etc.); the `_references` module is
|
|
49
|
+
# internal. Errors are re-exported from kento.errors. The remaining names are
|
|
50
|
+
# the long-standing module-level helpers defined below.
|
|
51
|
+
__all__ = [
|
|
52
|
+
# exception hierarchy (kento.errors)
|
|
53
|
+
"KentoError", "ValidationError", "InstanceNotFoundError",
|
|
54
|
+
"InstanceExistsError", "ImageNotFoundError", "ModeError", "StateError",
|
|
55
|
+
"StopTimeout", "SubprocessError",
|
|
56
|
+
# source-reference value types (Block 01 — kento._references)
|
|
57
|
+
"MalformedReference", "Endpoint", "Digest", "SourceReference",
|
|
58
|
+
"OciReference",
|
|
59
|
+
# network value types (Block 02 — kento._network)
|
|
60
|
+
"NetworkMode", "ForwardProtocol", "NetworkConnection",
|
|
61
|
+
"HostBinding", "GuestTarget", "ForwardAddressNotImplemented",
|
|
62
|
+
"parse_forward_spec", "render_forward_spec", "parse_forwards", "parse_cidr",
|
|
63
|
+
# diagnosis & report value types (Block 04 — kento._diagnosis)
|
|
64
|
+
"DiagnosisDomain", "CheckLevel", "PruneScope", "Finding", "Diagnosis",
|
|
65
|
+
"ReclaimReport",
|
|
66
|
+
# platform / lifecycle-status value types (Block 03 — kento._platform)
|
|
67
|
+
"PlatformMode", "PlatformProfile", "Status",
|
|
68
|
+
# root-storage value type (Block 03 — kento._storage)
|
|
69
|
+
"StorageMode",
|
|
70
|
+
# image family value types (Block 05 / SD1 — kento._images)
|
|
71
|
+
"DiskFormat", "Layer", "Image", "LayeredImage", "OciImage", "VolumeImage",
|
|
72
|
+
"CompositeImage",
|
|
73
|
+
# hold pin value type (storage-depth SD2 — kento._images)
|
|
74
|
+
"Hold",
|
|
75
|
+
# typed managed-image ledger (storage-depth SD3, JC1 — kento._images)
|
|
76
|
+
"ManagedStatus", "ImageRecord",
|
|
77
|
+
# instance family handles (Block 08 — kento._instances)
|
|
78
|
+
"Instance", "SystemContainer", "VirtualMachine",
|
|
79
|
+
# module-level helpers (defined in this module)
|
|
80
|
+
"validate_name", "detect_bridge", "resolve_network", "read_mode",
|
|
81
|
+
"require_root", "detect_mode", "upper_base", "sanitize_image_name",
|
|
82
|
+
"next_instance_name", "pve_config_exists", "is_running",
|
|
83
|
+
"resolve_container", "resolve_in_namespace", "resolve_any",
|
|
84
|
+
"check_name_conflict", "LXC_BASE", "VM_BASE",
|
|
85
|
+
# module-level diagnosis entry point (Block 10 — global host+images+instances)
|
|
86
|
+
"diagnose",
|
|
87
|
+
# module-level library version (Block 13, M26 — a property of the library)
|
|
88
|
+
"version",
|
|
89
|
+
]
|
|
22
90
|
|
|
23
91
|
LXC_BASE = Path("/var/lib/lxc")
|
|
24
92
|
VM_BASE = Path("/var/lib/kento/vm")
|
|
@@ -465,3 +533,80 @@ def check_name_conflict(name: str, target_namespace: str) -> bool:
|
|
|
465
533
|
else:
|
|
466
534
|
other_base = LXC_BASE
|
|
467
535
|
return _scan_namespace(name, other_base) is not None
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
# --------------------------------------------------------------------------- #
|
|
539
|
+
# Module-level diagnosis entry point — ``kento.diagnose()`` (Block 10, §11.8 D3).
|
|
540
|
+
#
|
|
541
|
+
# The global, host-wide diagnostic op: HOST checks + every image + every
|
|
542
|
+
# instance (both namespaces), mirroring the future ``kento.version()`` — a
|
|
543
|
+
# property of the library, not of a single handle. It is what ``kento diagnose``
|
|
544
|
+
# (no name) maps to. Companions ``instance.diagnose()`` / ``image.diagnose()``
|
|
545
|
+
# narrow to one domain/subject; this returns ALL findings (all three domains).
|
|
546
|
+
#
|
|
547
|
+
# THE NAME-COLLISION FOOT-GUN (gate C) — and why the import order below matters.
|
|
548
|
+
# There is a sibling SUBMODULE ``kento/diagnose.py`` (the procedural runtime;
|
|
549
|
+
# the CLI does ``from kento.diagnose import run_diagnostics``). On first import,
|
|
550
|
+
# Python binds the submodule as the parent package's ``diagnose`` attribute.
|
|
551
|
+
# Defining a top-level ``def diagnose`` here would normally be CLOBBERED right
|
|
552
|
+
# back by any later ``from kento.diagnose import ...`` that triggers a FRESH
|
|
553
|
+
# import of the submodule (a fresh import re-sets the parent attr to the module).
|
|
554
|
+
#
|
|
555
|
+
# Resolution (verified against real CPython, both import orders): import the
|
|
556
|
+
# submodule into ``sys.modules`` FIRST (the line below), THEN bind the function.
|
|
557
|
+
# Once the submodule is cached in ``sys.modules``, a subsequent
|
|
558
|
+
# ``from kento.diagnose import run_diagnostics`` finds the cached module and does
|
|
559
|
+
# NOT re-run the import machinery, so it does NOT re-set ``kento.diagnose`` — the
|
|
560
|
+
# function binding survives. ``kento.diagnose`` is the function; ``kento.diagnose
|
|
561
|
+
# .run_diagnostics`` still resolves through the cached submodule. The regression
|
|
562
|
+
# test (test_diagnose_module / test_instances) pins BOTH orders.
|
|
563
|
+
# --------------------------------------------------------------------------- #
|
|
564
|
+
from kento import diagnose as _diagnose_submodule # noqa: E402,F401 (cache it FIRST)
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def diagnose(name: str | None = None) -> "Diagnosis": # noqa: F821 (Diagnosis re-exported above)
|
|
568
|
+
"""Run the host-wide (or named) diagnostic scan (§11.8 D3 b).
|
|
569
|
+
|
|
570
|
+
Runs the existing ``kento.diagnose.run_diagnostics(name)`` and projects ALL
|
|
571
|
+
flat findings — UNFILTERED — into a typed :class:`Diagnosis`. Two modes,
|
|
572
|
+
distinguished only by the optional ``name``:
|
|
573
|
+
|
|
574
|
+
* ``name=None`` (default) — the whole-host scan: HOST pre-flight checks +
|
|
575
|
+
every image + every instance across both namespaces (all three domains —
|
|
576
|
+
INSTANCE / IMAGE / HOST), read-only / silent (it REPORTS, never reaps).
|
|
577
|
+
This is what ``kento diagnose`` (no instance argument) maps to.
|
|
578
|
+
* ``name=<str>`` — the same scan narrowed by ``run_diagnostics`` to the HOST
|
|
579
|
+
checks plus the ONE resolved instance's checks (raising
|
|
580
|
+
:class:`InstanceNotFoundError` on a miss, propagated unchanged). The
|
|
581
|
+
findings are projected UNFILTERED, preserving today's named-``diagnose``
|
|
582
|
+
wire (host findings + that instance's findings). This is DELIBERATELY
|
|
583
|
+
different from ``instance.diagnose()`` (M11), which filters to the
|
|
584
|
+
INSTANCE domain + itself and drops the host findings; the module-level
|
|
585
|
+
function is the legacy named-wire preserver, the handle method is the
|
|
586
|
+
narrowed per-instance view.
|
|
587
|
+
|
|
588
|
+
The module-level companion to ``instance.diagnose()`` (one instance's
|
|
589
|
+
INSTANCE findings) and ``image.diagnose()`` (the IMAGE domain); it mirrors
|
|
590
|
+
the future ``kento.version()`` as a property of the library.
|
|
591
|
+
|
|
592
|
+
Performs I/O (the scan) via an explicit module-level call (§2 principle 2);
|
|
593
|
+
the returned ``Diagnosis`` is an inert value type.
|
|
594
|
+
"""
|
|
595
|
+
from kento._diagnosis import diagnosis_from_report
|
|
596
|
+
|
|
597
|
+
report = _diagnose_submodule.run_diagnostics(name)
|
|
598
|
+
return diagnosis_from_report(report)
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def version() -> str:
|
|
602
|
+
"""Return the installed ``kento-core`` version string (§11.7 M26).
|
|
603
|
+
|
|
604
|
+
A module-level function (NOT a method — it is a property of the library, not
|
|
605
|
+
of any one instance, mirroring ``kento.diagnose()``). Minimal by design: it
|
|
606
|
+
returns the ``__version__`` resolved at import from ``importlib.metadata``
|
|
607
|
+
(the installed distribution's version), or ``"unknown"`` if the package
|
|
608
|
+
metadata is unavailable (e.g. running straight from a source tree without an
|
|
609
|
+
installed dist). A richer capability/feature-flag struct can come later,
|
|
610
|
+
non-breakingly, if a consumer needs one (§11.7).
|
|
611
|
+
"""
|
|
612
|
+
return __version__
|
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
"""Diagnosis & reclaim report value types — the inert result objects.
|
|
2
|
+
|
|
3
|
+
These are **pure, inert, frozen value types** (spec §2 principle 2): no I/O,
|
|
4
|
+
ever; a constructed value is plain data you can pass, copy, and reason about.
|
|
5
|
+
They are the *return* types of the handle methods that DO perform I/O
|
|
6
|
+
(``instance.diagnose()`` / ``image.diagnose()`` / ``kento.diagnose()`` and
|
|
7
|
+
``Image.prune`` / ``Instance.prune_orphans``) — those handle methods live in a
|
|
8
|
+
later block; this module ships only the inert objects they hand back.
|
|
9
|
+
|
|
10
|
+
The public surface (``DiagnosisDomain``, ``CheckLevel``, ``PruneScope``,
|
|
11
|
+
``Finding``, ``Diagnosis``, ``ReclaimReport``) is re-exported flat from
|
|
12
|
+
``kento`` — refer to ``kento.Diagnosis``, not ``kento._diagnosis.Diagnosis``.
|
|
13
|
+
|
|
14
|
+
The domain model (spec §11.8 D3): there are three diagnostic **domains**
|
|
15
|
+
(instance / image / host); a single scan emits **one ``Finding`` per subject**
|
|
16
|
+
including healthy ones (a healthy subject gets one ``OK`` finding). Coverage
|
|
17
|
+
("what was scanned") is therefore visible *in the findings*, and ``Diagnosis``
|
|
18
|
+
stores **no count fields** — ``ok`` and ``problems`` are **derived** from the
|
|
19
|
+
findings. Wire-format projections (``check``->``category``,
|
|
20
|
+
``WARNING``->``warn``, the ``problem_count``/``instances_scanned`` stats) are a
|
|
21
|
+
CLI-edge concern (§11.8 D1), deliberately NOT modelled here.
|
|
22
|
+
|
|
23
|
+
Spec: ``~/workspace/kento-core-api-design.md`` §2, §11.6, §11.8 (D3), §11.9.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import logging
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from enum import Enum
|
|
31
|
+
|
|
32
|
+
_diagnosis_logger = logging.getLogger("kento")
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"DiagnosisDomain",
|
|
36
|
+
"CheckLevel",
|
|
37
|
+
"PruneScope",
|
|
38
|
+
"Finding",
|
|
39
|
+
"Diagnosis",
|
|
40
|
+
"ReclaimReport",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# --------------------------------------------------------------------------- #
|
|
45
|
+
# Enums (§11.8 D3, §11.9).
|
|
46
|
+
#
|
|
47
|
+
# All three subclass ``str`` so their members compare/serialize as their wire
|
|
48
|
+
# value (``DiagnosisDomain.HOST == "host"``) — the same ``str, Enum`` shape the
|
|
49
|
+
# spec writes verbatim, matching the library's idiom for closed value sets.
|
|
50
|
+
# --------------------------------------------------------------------------- #
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class DiagnosisDomain(str, Enum):
|
|
54
|
+
"""The diagnostic domain a finding belongs to (§11.8 D3).
|
|
55
|
+
|
|
56
|
+
The three domains already implicit in today's flat output, given structure:
|
|
57
|
+
INSTANCE (status/network/mount/portfwd/cloudinit), IMAGE (hold-drift +
|
|
58
|
+
future dangling/stale/missing-layers), HOST (apparmor + future podman/nft/
|
|
59
|
+
store/disk, and cross-cutting registry/collection state — vmid/orphan).
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
INSTANCE = "instance"
|
|
63
|
+
IMAGE = "image"
|
|
64
|
+
HOST = "host"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class CheckLevel(str, Enum):
|
|
68
|
+
"""The severity of a single finding (§11.8 D3).
|
|
69
|
+
|
|
70
|
+
Library-first naming: ``ERROR`` is **restored** (the scan emits it) and the
|
|
71
|
+
idiomatic ``WARNING`` is kept — the wire format's ``warn`` is a CLI mapping
|
|
72
|
+
(§11.8 D1), not the library's vocabulary. ``OK`` marks a healthy subject.
|
|
73
|
+
"""
|
|
74
|
+
|
|
75
|
+
OK = "ok"
|
|
76
|
+
INFO = "info"
|
|
77
|
+
WARNING = "warning"
|
|
78
|
+
ERROR = "error"
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class PruneScope(str, Enum):
|
|
82
|
+
"""What a prune/reclaim operation targets (§11 M22, §11.9).
|
|
83
|
+
|
|
84
|
+
The shape is locked now — ``Image.prune`` takes a single ``scope:
|
|
85
|
+
PruneScope`` (replacing the awkward two-bool + builtin-shadowing ``all=``).
|
|
86
|
+
The method signature is LOCKED with a named default:
|
|
87
|
+
``Image.prune(*, scope: PruneScope = PruneScope.DANGLING)`` (spec line
|
|
88
|
+
1298), so ``DANGLING`` — the default scope the locked signature references —
|
|
89
|
+
ships now. The **further provenance scopes** (the kento-pulled-only vs
|
|
90
|
+
include-all distinction) are what "VALUES finalize with the EPIC" (§11.9
|
|
91
|
+
M22) defers; they land with the image-lifecycle EPIC, non-breakingly.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
DANGLING = "dangling"
|
|
95
|
+
# Further provenance scopes land WITH the image-lifecycle EPIC (§11.9 M22)
|
|
96
|
+
# — do NOT invent them here. DANGLING is present because the LOCKED M22
|
|
97
|
+
# signature (line 1298) names it as the default; pre-deciding the
|
|
98
|
+
# remaining provenance semantics is what the EPIC resolution defers.
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# --------------------------------------------------------------------------- #
|
|
102
|
+
# Finding — one diagnostic observation about one subject (§11.8 D3).
|
|
103
|
+
# --------------------------------------------------------------------------- #
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass(frozen=True)
|
|
107
|
+
class Finding:
|
|
108
|
+
"""One diagnostic observation — flat ``domain`` + ``subject`` (§11.8 D3).
|
|
109
|
+
|
|
110
|
+
``subject`` is a **flat string** identity (instance name / image ref), not a
|
|
111
|
+
nested type, per the flatten-a-closed-discriminator principle (§11.8 D3
|
|
112
|
+
micro-choice 1); it is ``None`` for HOST-domain findings (which are about
|
|
113
|
+
the host itself, not a named subject). ``check`` carries the **library**
|
|
114
|
+
word (``status``/``network``/``mount``/``portfwd``/``cloudinit``/
|
|
115
|
+
``hold-drift``/``apparmor``/``vmid``/``orphan``); the wire's ``category`` is
|
|
116
|
+
a CLI mapping. ``remediation`` (how to fix) is optional — present on
|
|
117
|
+
actionable findings, ``None`` otherwise.
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
domain: DiagnosisDomain
|
|
121
|
+
subject: str | None
|
|
122
|
+
check: str
|
|
123
|
+
level: CheckLevel
|
|
124
|
+
message: str
|
|
125
|
+
remediation: str | None = None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# --------------------------------------------------------------------------- #
|
|
129
|
+
# Diagnosis — a collection of findings; ok/problems DERIVED (§11.8 D3).
|
|
130
|
+
# --------------------------------------------------------------------------- #
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass(frozen=True)
|
|
134
|
+
class Diagnosis:
|
|
135
|
+
"""The result of a diagnostic scan — a bag of findings (§11.8 D3).
|
|
136
|
+
|
|
137
|
+
Holds only ``findings``. There are **no stored count fields**: a scan emits
|
|
138
|
+
one finding per subject (one ``OK`` finding per healthy subject), so
|
|
139
|
+
coverage is visible in the findings themselves and any statistic a consumer
|
|
140
|
+
wants — ``problem_count``, ``instances_scanned`` — is derived from them.
|
|
141
|
+
Keeping wire stats out of the type is deliberate (§11.8 D3 micro-choice 2).
|
|
142
|
+
|
|
143
|
+
``findings`` is a ``tuple`` so the value stays immutable end to end; a
|
|
144
|
+
``list`` argument is accepted and frozen at construction.
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
findings: tuple[Finding, ...] = ()
|
|
148
|
+
|
|
149
|
+
def __post_init__(self) -> None:
|
|
150
|
+
# Accept any finite iterable (the natural caller builds a list) and
|
|
151
|
+
# freeze it to a tuple so the value type stays genuinely immutable —
|
|
152
|
+
# a frozen dataclass freezes the *binding*, not a mutable list behind
|
|
153
|
+
# it. object.__setattr__ is the standard frozen-dataclass coercion idiom.
|
|
154
|
+
if not isinstance(self.findings, tuple):
|
|
155
|
+
object.__setattr__(self, "findings", tuple(self.findings))
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def problems(self) -> tuple[Finding, ...]:
|
|
159
|
+
"""The findings that represent a problem — WARNING or ERROR level.
|
|
160
|
+
|
|
161
|
+
Derived, never stored. ``OK``/``INFO`` findings (healthy subjects and
|
|
162
|
+
informational notes) are not problems.
|
|
163
|
+
"""
|
|
164
|
+
return tuple(
|
|
165
|
+
f for f in self.findings
|
|
166
|
+
if f.level in (CheckLevel.WARNING, CheckLevel.ERROR)
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
@property
|
|
170
|
+
def ok(self) -> bool:
|
|
171
|
+
"""Whether the scan found no problems — derived, never stored.
|
|
172
|
+
|
|
173
|
+
``True`` iff there are no WARNING/ERROR findings. An all-``OK``
|
|
174
|
+
diagnosis (every scanned subject healthy) is ``ok``; so is an empty
|
|
175
|
+
diagnosis (nothing scanned). ``ok`` is exactly ``not self.problems``.
|
|
176
|
+
"""
|
|
177
|
+
return not self.problems
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# --------------------------------------------------------------------------- #
|
|
181
|
+
# ReclaimReport — the prune/reclaim result (§11.6 M25).
|
|
182
|
+
# --------------------------------------------------------------------------- #
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@dataclass(frozen=True)
|
|
186
|
+
class ReclaimReport:
|
|
187
|
+
"""The result of a prune/reclaim batch — ONE shared type (§11.6 M25).
|
|
188
|
+
|
|
189
|
+
Returned by BOTH ``Image.prune`` (M22) and ``Instance.prune_orphans`` (M4):
|
|
190
|
+
a dry-run-able batch reclaim with surfaced failures. Targets are plain
|
|
191
|
+
string identifiers (image ref / instance name).
|
|
192
|
+
|
|
193
|
+
- ``dry_run`` — when ``True``, ``reclaimed`` is *would-remove*, nothing was
|
|
194
|
+
actually removed; when ``False`` it is what was removed.
|
|
195
|
+
- ``reclaimed`` — the targets removed (or would-be-removed under dry-run).
|
|
196
|
+
- ``failed`` — ``(target, reason)`` pairs for failures surfaced rather
|
|
197
|
+
than swallowed (the 1.6.2 failure-surfacing contract).
|
|
198
|
+
|
|
199
|
+
``ok`` is **derived** (``not self.failed``) — no stored flag. Both tuple
|
|
200
|
+
fields accept any iterable at construction and are frozen to tuples so the
|
|
201
|
+
value stays immutable.
|
|
202
|
+
"""
|
|
203
|
+
|
|
204
|
+
dry_run: bool
|
|
205
|
+
reclaimed: tuple[str, ...] = ()
|
|
206
|
+
failed: tuple[tuple[str, str], ...] = ()
|
|
207
|
+
|
|
208
|
+
def __post_init__(self) -> None:
|
|
209
|
+
# Freeze iterable arguments to tuples (see Diagnosis.__post_init__).
|
|
210
|
+
if not isinstance(self.reclaimed, tuple):
|
|
211
|
+
object.__setattr__(self, "reclaimed", tuple(self.reclaimed))
|
|
212
|
+
if not isinstance(self.failed, tuple):
|
|
213
|
+
object.__setattr__(
|
|
214
|
+
self, "failed", tuple(tuple(pair) for pair in self.failed)
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
@property
|
|
218
|
+
def ok(self) -> bool:
|
|
219
|
+
"""Whether the reclaim had no failures — derived, never stored."""
|
|
220
|
+
return not self.failed
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
# --------------------------------------------------------------------------- #
|
|
224
|
+
# The pure mapper — flat ``run_diagnostics`` findings -> typed ``Diagnosis``.
|
|
225
|
+
#
|
|
226
|
+
# This is the ONE place the procedural diagnose output is translated into the
|
|
227
|
+
# typed domain model (§11.8 D3). It is **pure / no I/O** (it consumes a result
|
|
228
|
+
# dict the I/O-performing entry points already obtained), so it lives here in
|
|
229
|
+
# the inert value-type module rather than in a handle module — keeping the three
|
|
230
|
+
# entry points (``instance.diagnose`` / ``image.diagnose`` / ``kento.diagnose``)
|
|
231
|
+
# DRY against a single mapping (the brief's "design these three coherently").
|
|
232
|
+
# --------------------------------------------------------------------------- #
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
# ``severity`` (wire word) -> ``CheckLevel`` (library word). The wire's ``warn``
|
|
236
|
+
# is the CLI mapping of the library's ``WARNING`` (§11.8 D3); every other value
|
|
237
|
+
# is identical to its enum value. An unknown severity is a forward/garbage value
|
|
238
|
+
# that must not crash the mapper — it degrades to ``CheckLevel.INFO`` with a log
|
|
239
|
+
# (total: one odd finding cannot blow up a whole diagnosis).
|
|
240
|
+
_SEVERITY_TO_LEVEL = {
|
|
241
|
+
"ok": CheckLevel.OK,
|
|
242
|
+
"info": CheckLevel.INFO,
|
|
243
|
+
"warn": CheckLevel.WARNING,
|
|
244
|
+
"error": CheckLevel.ERROR,
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
# ``category`` (the runtime flat ``category`` field, kept verbatim as
|
|
248
|
+
# ``Finding.check``) -> ``DiagnosisDomain``. THE single source of truth for
|
|
249
|
+
# domain assignment (§11.8 D3): INSTANCE = the per-instance checks; IMAGE = the
|
|
250
|
+
# hold checks (the runtime category is ``"hold"`` — both stale-hold and
|
|
251
|
+
# hold-drift use it); HOST = apparmor + the cross-cutting registry/collection
|
|
252
|
+
# state (vmid/orphan), which Jei explicitly kept in the HOST domain rather than
|
|
253
|
+
# split into INSTANCE. An unknown/future category is NOT in this map and falls
|
|
254
|
+
# back to a safe default in :func:`_domain_for_category` (see below).
|
|
255
|
+
_CATEGORY_TO_DOMAIN = {
|
|
256
|
+
# INSTANCE domain — per-instance checks.
|
|
257
|
+
"status": DiagnosisDomain.INSTANCE,
|
|
258
|
+
"network": DiagnosisDomain.INSTANCE,
|
|
259
|
+
"mount": DiagnosisDomain.INSTANCE,
|
|
260
|
+
"portfwd": DiagnosisDomain.INSTANCE,
|
|
261
|
+
"cloudinit": DiagnosisDomain.INSTANCE,
|
|
262
|
+
# IMAGE domain — image-hold health (runtime category is "hold").
|
|
263
|
+
"hold": DiagnosisDomain.IMAGE,
|
|
264
|
+
# HOST domain — host pre-flight + cross-cutting registry/collection state.
|
|
265
|
+
"apparmor": DiagnosisDomain.HOST,
|
|
266
|
+
"vmid": DiagnosisDomain.HOST,
|
|
267
|
+
"orphan": DiagnosisDomain.HOST,
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
def _domain_for_category(category: str) -> DiagnosisDomain:
|
|
272
|
+
"""Map a runtime ``category`` to its ``DiagnosisDomain`` (§11.8 D3) — TOTAL.
|
|
273
|
+
|
|
274
|
+
A known category maps per :data:`_CATEGORY_TO_DOMAIN`. An UNKNOWN/future
|
|
275
|
+
category (a runtime check added later, before this map is updated) MUST NOT
|
|
276
|
+
crash the mapper: it degrades to ``DiagnosisDomain.HOST`` with a log. HOST is
|
|
277
|
+
the safe default — it is the catch-all host/collection domain (apparmor,
|
|
278
|
+
vmid, orphan already live there), so an unclassified finding surfaces under
|
|
279
|
+
the host scan (``kento.diagnose``) rather than being silently dropped or
|
|
280
|
+
mis-attributed to a specific instance/image it may not concern.
|
|
281
|
+
"""
|
|
282
|
+
domain = _CATEGORY_TO_DOMAIN.get(category)
|
|
283
|
+
if domain is not None:
|
|
284
|
+
return domain
|
|
285
|
+
_diagnosis_logger.warning(
|
|
286
|
+
"unrecognized diagnose category %r; classifying as HOST domain",
|
|
287
|
+
category,
|
|
288
|
+
)
|
|
289
|
+
return DiagnosisDomain.HOST
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _subject_for_finding(
|
|
293
|
+
domain: DiagnosisDomain, scope: str, category: str,
|
|
294
|
+
) -> str | None:
|
|
295
|
+
"""Derive a ``Finding.subject`` from the runtime finding (§11.8 D3).
|
|
296
|
+
|
|
297
|
+
The runtime ``scope`` field is either the literal ``"host"`` or an instance
|
|
298
|
+
name. The typed ``subject`` is the flat string identity a finding is about
|
|
299
|
+
(instance name / image ref), or ``None`` when the finding is about the host
|
|
300
|
+
itself, not a named subject. Derivation (per the brief's #2):
|
|
301
|
+
|
|
302
|
+
* INSTANCE domain — ``subject`` = the runtime ``scope`` (the instance name).
|
|
303
|
+
* HOST apparmor / vmid — ``scope == "host"`` => ``subject = None`` (about the
|
|
304
|
+
host, no named subject).
|
|
305
|
+
* HOST ``orphan`` — its runtime ``scope`` IS the instance name even though
|
|
306
|
+
its domain is HOST (Jei kept orphan in HOST as registry/collection state).
|
|
307
|
+
The most faithful subject is that instance name, so we carry it through:
|
|
308
|
+
``subject != None`` is allowed for HOST (the "None for HOST" rule of §11.8
|
|
309
|
+
describes apparmor-style host findings, not a hard invariant).
|
|
310
|
+
* IMAGE ``hold`` — the runtime finding's ``scope`` is ``"host"`` and the
|
|
311
|
+
image ref is embedded only in the message TEXT; the additive wrapper must
|
|
312
|
+
NOT parse messages (§2 principle 3 — never re-split by hand), so the
|
|
313
|
+
subject is ``None`` here. This is a DOCUMENTED limitation: clean per-image
|
|
314
|
+
attribution lands with the lifecycle EPIC that refactors ``diagnose.py``.
|
|
315
|
+
|
|
316
|
+
The general rule that implements all four: a literal ``"host"`` scope yields
|
|
317
|
+
``None``; any other scope is a real subject id carried through verbatim. The
|
|
318
|
+
orphan case falls out for free (its scope is the instance name, not
|
|
319
|
+
``"host"``), and the hold case falls out for free (its scope IS ``"host"``).
|
|
320
|
+
"""
|
|
321
|
+
if scope == "host":
|
|
322
|
+
return None
|
|
323
|
+
return scope
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def diagnosis_from_report(
|
|
327
|
+
report: dict,
|
|
328
|
+
*,
|
|
329
|
+
domain: DiagnosisDomain | None = None,
|
|
330
|
+
subject: str | None = None,
|
|
331
|
+
) -> Diagnosis:
|
|
332
|
+
"""Map a ``run_diagnostics`` result dict to a typed ``Diagnosis`` (§11.8 D3).
|
|
333
|
+
|
|
334
|
+
PURE — no I/O. Takes the flat result the (I/O-performing) entry points
|
|
335
|
+
obtain from ``kento.diagnose.run_diagnostics`` and produces the typed
|
|
336
|
+
``Diagnosis``, translating each flat finding into a ``Finding``:
|
|
337
|
+
|
|
338
|
+
* ``severity`` -> ``CheckLevel`` (``ok``/``info``/``warn``->WARNING/
|
|
339
|
+
``error``), via :data:`_SEVERITY_TO_LEVEL` (unknown -> INFO + log);
|
|
340
|
+
* ``category`` carried VERBATIM into ``check`` (the library word; the wire's
|
|
341
|
+
``category`` is itself the library word here — §11.8 D3);
|
|
342
|
+
* ``domain`` derived from ``category`` (:func:`_domain_for_category`);
|
|
343
|
+
* ``subject`` derived from ``scope``/``domain`` (:func:`_subject_for_finding`);
|
|
344
|
+
* ``message`` / ``remediation`` carried through (``remediation`` may be
|
|
345
|
+
``None``).
|
|
346
|
+
|
|
347
|
+
The optional ``domain`` / ``subject`` keyword filters narrow the result so a
|
|
348
|
+
single entry point can project just its slice WITHOUT re-running the scan:
|
|
349
|
+
``instance.diagnose()`` passes ``domain=INSTANCE, subject=<its name>``;
|
|
350
|
+
``image.diagnose()`` passes ``domain=IMAGE``; ``kento.diagnose()`` passes
|
|
351
|
+
neither (all findings). A finding is kept iff it matches every supplied
|
|
352
|
+
filter (a ``None`` filter is "don't filter on this axis").
|
|
353
|
+
"""
|
|
354
|
+
findings: list[Finding] = []
|
|
355
|
+
for raw in report.get("checks", []):
|
|
356
|
+
category = raw.get("category", "")
|
|
357
|
+
scope = raw.get("scope", "")
|
|
358
|
+
severity = raw.get("severity", "")
|
|
359
|
+
|
|
360
|
+
f_domain = _domain_for_category(category)
|
|
361
|
+
f_subject = _subject_for_finding(f_domain, scope, category)
|
|
362
|
+
level = _SEVERITY_TO_LEVEL.get(severity)
|
|
363
|
+
if level is None:
|
|
364
|
+
_diagnosis_logger.warning(
|
|
365
|
+
"unrecognized diagnose severity %r (category %r); "
|
|
366
|
+
"classifying as INFO", severity, category,
|
|
367
|
+
)
|
|
368
|
+
level = CheckLevel.INFO
|
|
369
|
+
|
|
370
|
+
# Apply the optional narrowing filters (None = don't filter on that axis).
|
|
371
|
+
if domain is not None and f_domain is not domain:
|
|
372
|
+
continue
|
|
373
|
+
if subject is not None and f_subject != subject:
|
|
374
|
+
continue
|
|
375
|
+
|
|
376
|
+
findings.append(Finding(
|
|
377
|
+
domain=f_domain,
|
|
378
|
+
subject=f_subject,
|
|
379
|
+
check=category,
|
|
380
|
+
level=level,
|
|
381
|
+
message=raw.get("message", ""),
|
|
382
|
+
remediation=raw.get("remediation"),
|
|
383
|
+
))
|
|
384
|
+
return Diagnosis(findings=tuple(findings))
|