kento-core 1.6.0.dev3__tar.gz → 1.7.0.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.
Files changed (104) hide show
  1. {kento_core-1.6.0.dev3/src/kento_core.egg-info → kento_core-1.7.0.dev0}/PKG-INFO +3 -1
  2. {kento_core-1.6.0.dev3 → kento_core-1.7.0.dev0}/README.md +12 -0
  3. {kento_core-1.6.0.dev3 → kento_core-1.7.0.dev0}/pyproject.toml +7 -1
  4. {kento_core-1.6.0.dev3 → kento_core-1.7.0.dev0}/src/kento/__init__.py +168 -3
  5. kento_core-1.7.0.dev0/src/kento/_diagnosis.py +384 -0
  6. kento_core-1.7.0.dev0/src/kento/_images.py +1782 -0
  7. kento_core-1.7.0.dev0/src/kento/_instances.py +3737 -0
  8. kento_core-1.7.0.dev0/src/kento/_network.py +433 -0
  9. kento_core-1.7.0.dev0/src/kento/_platform.py +187 -0
  10. kento_core-1.7.0.dev0/src/kento/_references.py +1058 -0
  11. kento_core-1.7.0.dev0/src/kento/_result.py +499 -0
  12. kento_core-1.7.0.dev0/src/kento/_storage.py +65 -0
  13. {kento_core-1.6.0.dev3 → kento_core-1.7.0.dev0}/src/kento/create.py +390 -61
  14. {kento_core-1.6.0.dev3 → kento_core-1.7.0.dev0}/src/kento/errors.py +13 -0
  15. kento_core-1.7.0.dev0/src/kento/exec_cmd.py +118 -0
  16. kento_core-1.7.0.dev0/src/kento/extract.py +192 -0
  17. kento_core-1.7.0.dev0/src/kento/fetch.py +378 -0
  18. {kento_core-1.6.0.dev3 → kento_core-1.7.0.dev0}/src/kento/hook.sh +192 -71
  19. {kento_core-1.6.0.dev3 → kento_core-1.7.0.dev0}/src/kento/images.py +20 -57
  20. {kento_core-1.6.0.dev3 → kento_core-1.7.0.dev0}/src/kento/info.py +10 -1
  21. kento_core-1.7.0.dev0/src/kento/portfwd.py +393 -0
  22. {kento_core-1.6.0.dev3 → kento_core-1.7.0.dev0}/src/kento/pve.py +26 -6
  23. {kento_core-1.6.0.dev3 → kento_core-1.7.0.dev0}/src/kento/set_cmd.py +33 -10
  24. {kento_core-1.6.0.dev3 → kento_core-1.7.0.dev0}/src/kento/stop.py +14 -1
  25. {kento_core-1.6.0.dev3 → kento_core-1.7.0.dev0}/src/kento/suspend.py +32 -0
  26. kento_core-1.7.0.dev0/src/kento/urlvm.py +96 -0
  27. {kento_core-1.6.0.dev3 → kento_core-1.7.0.dev0}/src/kento/vm.py +124 -34
  28. {kento_core-1.6.0.dev3 → kento_core-1.7.0.dev0}/src/kento/vm_hook.py +38 -7
  29. {kento_core-1.6.0.dev3 → kento_core-1.7.0.dev0/src/kento_core.egg-info}/PKG-INFO +3 -1
  30. {kento_core-1.6.0.dev3 → kento_core-1.7.0.dev0}/src/kento_core.egg-info/SOURCES.txt +29 -0
  31. kento_core-1.7.0.dev0/src/kento_core.egg-info/requires.txt +3 -0
  32. {kento_core-1.6.0.dev3 → kento_core-1.7.0.dev0}/tests/test_create.py +596 -2
  33. {kento_core-1.6.0.dev3 → kento_core-1.7.0.dev0}/tests/test_create_locking.py +1 -1
  34. kento_core-1.7.0.dev0/tests/test_diagnose_module.py +242 -0
  35. kento_core-1.7.0.dev0/tests/test_diagnosis.py +552 -0
  36. kento_core-1.7.0.dev0/tests/test_exec.py +229 -0
  37. kento_core-1.7.0.dev0/tests/test_extract.py +404 -0
  38. kento_core-1.7.0.dev0/tests/test_fetch.py +761 -0
  39. kento_core-1.7.0.dev0/tests/test_hold.py +289 -0
  40. {kento_core-1.6.0.dev3 → kento_core-1.7.0.dev0}/tests/test_hook.py +33 -17
  41. kento_core-1.7.0.dev0/tests/test_image_ops.py +784 -0
  42. kento_core-1.7.0.dev0/tests/test_image_record.py +513 -0
  43. {kento_core-1.6.0.dev3 → kento_core-1.7.0.dev0}/tests/test_images.py +15 -79
  44. kento_core-1.7.0.dev0/tests/test_images_types.py +582 -0
  45. {kento_core-1.6.0.dev3 → kento_core-1.7.0.dev0}/tests/test_init.py +39 -17
  46. kento_core-1.7.0.dev0/tests/test_instances.py +3267 -0
  47. kento_core-1.7.0.dev0/tests/test_network.py +387 -0
  48. kento_core-1.7.0.dev0/tests/test_platform.py +201 -0
  49. kento_core-1.7.0.dev0/tests/test_portfwd.py +193 -0
  50. {kento_core-1.6.0.dev3 → kento_core-1.7.0.dev0}/tests/test_pve.py +62 -0
  51. kento_core-1.7.0.dev0/tests/test_references.py +1174 -0
  52. kento_core-1.7.0.dev0/tests/test_result.py +590 -0
  53. {kento_core-1.6.0.dev3 → kento_core-1.7.0.dev0}/tests/test_set.py +89 -2
  54. {kento_core-1.6.0.dev3 → kento_core-1.7.0.dev0}/tests/test_stop.py +20 -0
  55. kento_core-1.7.0.dev0/tests/test_storage.py +48 -0
  56. {kento_core-1.6.0.dev3 → kento_core-1.7.0.dev0}/tests/test_suspend.py +45 -1
  57. kento_core-1.7.0.dev0/tests/test_urlvm.py +328 -0
  58. {kento_core-1.6.0.dev3 → kento_core-1.7.0.dev0}/tests/test_vm.py +250 -0
  59. {kento_core-1.6.0.dev3 → kento_core-1.7.0.dev0}/tests/test_vm_hook.py +68 -0
  60. kento_core-1.6.0.dev3/src/kento/exec_cmd.py +0 -50
  61. kento_core-1.6.0.dev3/tests/test_exec.py +0 -128
  62. {kento_core-1.6.0.dev3 → kento_core-1.7.0.dev0}/LICENSE.md +0 -0
  63. {kento_core-1.6.0.dev3 → kento_core-1.7.0.dev0}/setup.cfg +0 -0
  64. {kento_core-1.6.0.dev3 → kento_core-1.7.0.dev0}/src/kento/attach.py +0 -0
  65. {kento_core-1.6.0.dev3 → kento_core-1.7.0.dev0}/src/kento/cloudinit.py +0 -0
  66. {kento_core-1.6.0.dev3 → kento_core-1.7.0.dev0}/src/kento/defaults.py +0 -0
  67. {kento_core-1.6.0.dev3 → kento_core-1.7.0.dev0}/src/kento/destroy.py +0 -0
  68. {kento_core-1.6.0.dev3 → kento_core-1.7.0.dev0}/src/kento/diagnose.py +0 -0
  69. {kento_core-1.6.0.dev3 → kento_core-1.7.0.dev0}/src/kento/hook.py +0 -0
  70. {kento_core-1.6.0.dev3 → kento_core-1.7.0.dev0}/src/kento/inject.py +0 -0
  71. {kento_core-1.6.0.dev3 → kento_core-1.7.0.dev0}/src/kento/inject.sh +0 -0
  72. {kento_core-1.6.0.dev3 → kento_core-1.7.0.dev0}/src/kento/layers.py +0 -0
  73. {kento_core-1.6.0.dev3 → kento_core-1.7.0.dev0}/src/kento/list.py +0 -0
  74. {kento_core-1.6.0.dev3 → kento_core-1.7.0.dev0}/src/kento/locking.py +0 -0
  75. {kento_core-1.6.0.dev3 → kento_core-1.7.0.dev0}/src/kento/logs.py +0 -0
  76. {kento_core-1.6.0.dev3 → kento_core-1.7.0.dev0}/src/kento/lxc_hook.py +0 -0
  77. {kento_core-1.6.0.dev3 → kento_core-1.7.0.dev0}/src/kento/reconcile.py +0 -0
  78. {kento_core-1.6.0.dev3 → kento_core-1.7.0.dev0}/src/kento/reset.py +0 -0
  79. {kento_core-1.6.0.dev3 → kento_core-1.7.0.dev0}/src/kento/start.py +0 -0
  80. {kento_core-1.6.0.dev3 → kento_core-1.7.0.dev0}/src/kento/subprocess_util.py +0 -0
  81. {kento_core-1.6.0.dev3 → kento_core-1.7.0.dev0}/src/kento_core.egg-info/dependency_links.txt +0 -0
  82. {kento_core-1.6.0.dev3 → kento_core-1.7.0.dev0}/src/kento_core.egg-info/top_level.txt +0 -0
  83. {kento_core-1.6.0.dev3 → kento_core-1.7.0.dev0}/tests/test_adopt.py +0 -0
  84. {kento_core-1.6.0.dev3 → kento_core-1.7.0.dev0}/tests/test_attach.py +0 -0
  85. {kento_core-1.6.0.dev3 → kento_core-1.7.0.dev0}/tests/test_cloudinit.py +0 -0
  86. {kento_core-1.6.0.dev3 → kento_core-1.7.0.dev0}/tests/test_create_passthrough.py +0 -0
  87. {kento_core-1.6.0.dev3 → kento_core-1.7.0.dev0}/tests/test_defaults.py +0 -0
  88. {kento_core-1.6.0.dev3 → kento_core-1.7.0.dev0}/tests/test_destroy.py +0 -0
  89. {kento_core-1.6.0.dev3 → kento_core-1.7.0.dev0}/tests/test_diagnose.py +0 -0
  90. {kento_core-1.6.0.dev3 → kento_core-1.7.0.dev0}/tests/test_errors.py +0 -0
  91. {kento_core-1.6.0.dev3 → kento_core-1.7.0.dev0}/tests/test_image_hold.py +0 -0
  92. {kento_core-1.6.0.dev3 → kento_core-1.7.0.dev0}/tests/test_info.py +0 -0
  93. {kento_core-1.6.0.dev3 → kento_core-1.7.0.dev0}/tests/test_inject.py +0 -0
  94. {kento_core-1.6.0.dev3 → kento_core-1.7.0.dev0}/tests/test_layers.py +0 -0
  95. {kento_core-1.6.0.dev3 → kento_core-1.7.0.dev0}/tests/test_list.py +0 -0
  96. {kento_core-1.6.0.dev3 → kento_core-1.7.0.dev0}/tests/test_locking.py +0 -0
  97. {kento_core-1.6.0.dev3 → kento_core-1.7.0.dev0}/tests/test_logs.py +0 -0
  98. {kento_core-1.6.0.dev3 → kento_core-1.7.0.dev0}/tests/test_lxc_hook.py +0 -0
  99. {kento_core-1.6.0.dev3 → kento_core-1.7.0.dev0}/tests/test_reconcile.py +0 -0
  100. {kento_core-1.6.0.dev3 → kento_core-1.7.0.dev0}/tests/test_reset.py +0 -0
  101. {kento_core-1.6.0.dev3 → kento_core-1.7.0.dev0}/tests/test_run_or_die_integration.py +0 -0
  102. {kento_core-1.6.0.dev3 → kento_core-1.7.0.dev0}/tests/test_start.py +0 -0
  103. {kento_core-1.6.0.dev3 → kento_core-1.7.0.dev0}/tests/test_subprocess_util.py +0 -0
  104. {kento_core-1.6.0.dev3 → kento_core-1.7.0.dev0}/tests/test_validate_name.py +0 -0
@@ -1,8 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kento-core
3
- Version: 1.6.0.dev3
3
+ Version: 1.7.0.dev0
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
7
7
  License-File: LICENSE.md
8
+ Provides-Extra: docs
9
+ Requires-Dist: pdoc>=14; extra == "docs"
8
10
  Dynamic: license-file
@@ -396,8 +396,20 @@ and its writable layer. Errors if the instance is running unless
396
396
  └── rootfs/ # Overlayfs mount point
397
397
  ```
398
398
 
399
+ ## Environment overrides
400
+
401
+ - `KENTO_STATE_DIR` — base directory for the writable overlay layer (sidesteps
402
+ overlay-on-overlay when the default location sits on an overlayfs).
403
+ - `KENTO_URL_MAX_BYTES` — size cap for a URL fetch (VM URL-source rootfs /
404
+ kernel / initramfs). Default **2 GiB**; a fetch exceeding the cap fails.
405
+ - `KENTO_APPARMOR_PROFILE` — override the emitted plain-LXC AppArmor profile
406
+ (default `generated`).
407
+
399
408
  ## Documentation
400
409
 
410
+ - [Library Guide](docs/library-guide.md) — using `kento-core` as an importable
411
+ Python library (`import kento`): the typed object model, `Result` handling,
412
+ and URL-VM from code. Start here if you are a library consumer.
401
413
  - [Getting Started](docs/getting-started.md) — install, first instance walkthrough
402
414
  - [Modes](docs/modes.md) — lxc vs pve-lxc vs vm vs pve-vm, auto-detection, defaults
403
415
  - [VM Mode](docs/vm-mode.md) — image requirements, SSH access, port forwarding
@@ -4,13 +4,19 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "kento-core"
7
- version = "1.6.0.dev3"
7
+ version = "1.7.0.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"
11
11
 
12
12
  # NO [project.scripts] — the console entry point lives in kento-cli (separate repo).
13
13
 
14
+ # Optional dependency groups. `docs` supplies the API-reference generator
15
+ # (pdoc renders the public docstrings to HTML). Install + generate with:
16
+ # pip install -e '.[docs]' then make docs
17
+ [project.optional-dependencies]
18
+ docs = ["pdoc>=14"]
19
+
14
20
  [tool.setuptools.packages.find]
15
21
  where = ["src"]
16
22
 
@@ -17,9 +17,85 @@ 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
+ )
22
+ from kento._references import ( # noqa: F401 (public re-export)
23
+ MalformedReference, Endpoint, Digest, SourceReference, OciReference,
24
+ UrlReference,
25
+ )
26
+ from kento._network import ( # noqa: F401 (public re-export)
27
+ NetworkMode, ForwardProtocol, NetworkConnection,
28
+ HostBinding, GuestTarget, ForwardAddressNotImplemented,
29
+ parse_forward_spec, render_forward_spec, parse_forwards, parse_cidr,
30
+ )
31
+ from kento._diagnosis import ( # noqa: F401 (public re-export)
32
+ DiagnosisDomain, CheckLevel, PruneScope, Finding, Diagnosis, ReclaimReport,
33
+ )
34
+ from kento._platform import ( # noqa: F401 (public re-export)
35
+ PlatformMode, PlatformProfile, Status,
36
+ )
37
+ from kento._storage import ( # noqa: F401 (public re-export)
38
+ StorageMode,
39
+ )
40
+ from kento._images import ( # noqa: F401 (public re-export)
41
+ DiskFormat, Layer, Hold, ManagedStatus, ImageRecord, Image, LayeredImage,
42
+ OciImage, LocalDirectoryImage, VolumeImage, CompositeImage,
43
+ )
44
+ from kento._instances import ( # noqa: F401 (public re-export)
45
+ Instance, SystemContainer, VirtualMachine,
46
+ )
47
+ from kento._result import ( # noqa: F401 (public re-export)
48
+ Result, Ok, Warning, Error, Condition, Severity, ConditionKind,
49
+ ResultError,
21
50
  )
22
51
 
52
+ # Curated public surface. The source-reference value types are re-exported
53
+ # flat (canonical paths kento.OciReference etc.); the `_references` module is
54
+ # internal. Errors are re-exported from kento.errors. The remaining names are
55
+ # the long-standing module-level helpers defined below.
56
+ __all__ = [
57
+ # exception hierarchy (kento.errors)
58
+ "KentoError", "ValidationError", "InstanceNotFoundError",
59
+ "InstanceExistsError", "ImageNotFoundError", "ModeError", "StateError",
60
+ "StopTimeout", "SubprocessError",
61
+ # source-reference value types (Block 01 — kento._references)
62
+ "MalformedReference", "Endpoint", "Digest", "SourceReference",
63
+ "OciReference", "UrlReference",
64
+ # network value types (Block 02 — kento._network)
65
+ "NetworkMode", "ForwardProtocol", "NetworkConnection",
66
+ "HostBinding", "GuestTarget", "ForwardAddressNotImplemented",
67
+ "parse_forward_spec", "render_forward_spec", "parse_forwards", "parse_cidr",
68
+ # diagnosis & report value types (Block 04 — kento._diagnosis)
69
+ "DiagnosisDomain", "CheckLevel", "PruneScope", "Finding", "Diagnosis",
70
+ "ReclaimReport",
71
+ # platform / lifecycle-status value types (Block 03 — kento._platform)
72
+ "PlatformMode", "PlatformProfile", "Status",
73
+ # root-storage value type (Block 03 — kento._storage)
74
+ "StorageMode",
75
+ # image family value types (Block 05 / SD1 — kento._images)
76
+ "DiskFormat", "Layer", "Image", "LayeredImage", "OciImage",
77
+ "LocalDirectoryImage", "VolumeImage", "CompositeImage",
78
+ # hold pin value type (storage-depth SD2 — kento._images)
79
+ "Hold",
80
+ # typed managed-image ledger (storage-depth SD3, JC1 — kento._images)
81
+ "ManagedStatus", "ImageRecord",
82
+ # instance family handles (Block 08 — kento._instances)
83
+ "Instance", "SystemContainer", "VirtualMachine",
84
+ # Result value family (Block R1 — kento._result)
85
+ "Result", "Ok", "Warning", "Error", "Condition", "Severity",
86
+ "ConditionKind", "ResultError",
87
+ # module-level helpers (defined in this module)
88
+ "validate_name", "detect_bridge", "read_mode",
89
+ "require_root", "detect_mode", "upper_base", "sanitize_image_name",
90
+ "next_instance_name", "pve_config_exists", "is_running",
91
+ "resolve_container", "resolve_in_namespace", "resolve_any",
92
+ "check_name_conflict", "LXC_BASE", "VM_BASE",
93
+ # module-level diagnosis entry point (Block 10 — global host+images+instances)
94
+ "diagnose",
95
+ # module-level library version (Block 13, M26 — a property of the library)
96
+ "version",
97
+ ]
98
+
23
99
  LXC_BASE = Path("/var/lib/lxc")
24
100
  VM_BASE = Path("/var/lib/kento/vm")
25
101
 
@@ -75,8 +151,8 @@ def detect_bridge() -> str | None:
75
151
  return None
76
152
 
77
153
 
78
- def resolve_network(net_type: str | None, bridge_name: str | None,
79
- mode: str, port: str | None = None) -> dict:
154
+ def _resolve_network(net_type: str | None, bridge_name: str | None,
155
+ mode: str, port: str | None = None) -> dict:
80
156
  """Resolve network configuration for container/VM creation.
81
157
 
82
158
  Returns dict with keys: type, bridge, port
@@ -465,3 +541,92 @@ def check_name_conflict(name: str, target_namespace: str) -> bool:
465
541
  else:
466
542
  other_base = LXC_BASE
467
543
  return _scan_namespace(name, other_base) is not None
544
+
545
+
546
+ # --------------------------------------------------------------------------- #
547
+ # Module-level diagnosis entry point — ``kento.diagnose()`` (Block 10, §11.8 D3).
548
+ #
549
+ # The global, host-wide diagnostic op: HOST checks + every image + every
550
+ # instance (both namespaces), mirroring the future ``kento.version()`` — a
551
+ # property of the library, not of a single handle. It is what ``kento diagnose``
552
+ # (no name) maps to. Companions ``instance.diagnose()`` / ``image.diagnose()``
553
+ # narrow to one domain/subject; this returns ALL findings (all three domains).
554
+ #
555
+ # THE NAME-COLLISION FOOT-GUN (gate C) — and why the import order below matters.
556
+ # There is a sibling SUBMODULE ``kento/diagnose.py`` (the procedural runtime;
557
+ # the CLI does ``from kento.diagnose import run_diagnostics``). On first import,
558
+ # Python binds the submodule as the parent package's ``diagnose`` attribute.
559
+ # Defining a top-level ``def diagnose`` here would normally be CLOBBERED right
560
+ # back by any later ``from kento.diagnose import ...`` that triggers a FRESH
561
+ # import of the submodule (a fresh import re-sets the parent attr to the module).
562
+ #
563
+ # Resolution (verified against real CPython, both import orders): import the
564
+ # submodule into ``sys.modules`` FIRST (the line below), THEN bind the function.
565
+ # Once the submodule is cached in ``sys.modules``, a subsequent
566
+ # ``from kento.diagnose import run_diagnostics`` finds the cached module and does
567
+ # NOT re-run the import machinery, so it does NOT re-set ``kento.diagnose`` — the
568
+ # function binding survives. ``kento.diagnose`` is the function; ``kento.diagnose
569
+ # .run_diagnostics`` still resolves through the cached submodule. The regression
570
+ # test (test_diagnose_module / test_instances) pins BOTH orders.
571
+ # --------------------------------------------------------------------------- #
572
+ from kento import diagnose as _diagnose_submodule # noqa: E402,F401 (cache it FIRST)
573
+
574
+
575
+ def diagnose(name: str | None = None) -> Result["Diagnosis"]: # noqa: F821 (Diagnosis re-exported above)
576
+ """Run the host-wide (or named) diagnostic scan (§11.8 D3 b).
577
+
578
+ Runs the existing ``kento.diagnose.run_diagnostics(name)`` and projects ALL
579
+ flat findings — UNFILTERED — into a typed :class:`Diagnosis`. Two modes,
580
+ distinguished only by the optional ``name``:
581
+
582
+ * ``name=None`` (default) — the whole-host scan: HOST pre-flight checks +
583
+ every image + every instance across both namespaces (all three domains —
584
+ INSTANCE / IMAGE / HOST), read-only / silent (it REPORTS, never reaps).
585
+ This is what ``kento diagnose`` (no instance argument) maps to.
586
+ * ``name=<str>`` — the same scan narrowed by ``run_diagnostics`` to the HOST
587
+ checks plus the ONE resolved instance's checks. The findings are projected
588
+ UNFILTERED, preserving today's named-``diagnose`` wire (host findings +
589
+ that instance's findings). This is DELIBERATELY different from
590
+ ``instance.diagnose()`` (M11), which filters to the INSTANCE domain +
591
+ itself and drops the host findings; the module-level function is the
592
+ legacy named-wire preserver, the handle method is the narrowed
593
+ per-instance view.
594
+
595
+ The module-level companion to ``instance.diagnose()`` (one instance's
596
+ INSTANCE findings) and ``image.diagnose()`` (the IMAGE domain); it mirrors
597
+ the future ``kento.version()`` as a property of the library.
598
+
599
+ Performs I/O (the scan) via an explicit module-level call (§2 principle 2);
600
+ the returned ``Diagnosis`` is an inert value type.
601
+
602
+ Public Result boundary (Result-propagation sweep, Block S6): the normal scan
603
+ returns ``Ok(Diagnosis)``. The DEGRADED findings are part of a SUCCESSFUL
604
+ scan — a no-root / unreadable check degrades to an ``info``/``error`` FINDING
605
+ *inside* the report, never an ``Error`` of the Result (the scan still
606
+ succeeded in producing a report). The ONE predictable failure is a named
607
+ scoped miss: ``run_diagnostics(name)`` raises ``InstanceNotFoundError`` for
608
+ an unknown ``name``, caught here -> ``Error(INSTANCE_NOT_FOUND)``. A
609
+ non-``KentoError`` is a panic and propagates.
610
+ """
611
+ from kento._diagnosis import diagnosis_from_report
612
+ from kento._result import _error_from
613
+
614
+ try:
615
+ report = _diagnose_submodule.run_diagnostics(name)
616
+ return Ok(value=diagnosis_from_report(report))
617
+ except KentoError as exc:
618
+ return _error_from(exc)
619
+
620
+
621
+ def version() -> str:
622
+ """Return the installed ``kento-core`` version string (§11.7 M26).
623
+
624
+ A module-level function (NOT a method — it is a property of the library, not
625
+ of any one instance, mirroring ``kento.diagnose()``). Minimal by design: it
626
+ returns the ``__version__`` resolved at import from ``importlib.metadata``
627
+ (the installed distribution's version), or ``"unknown"`` if the package
628
+ metadata is unavailable (e.g. running straight from a source tree without an
629
+ installed dist). A richer capability/feature-flag struct can come later,
630
+ non-breakingly, if a consumer needs one (§11.7).
631
+ """
632
+ 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))