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.
Files changed (95) hide show
  1. {kento_core-1.6.0.dev3/src/kento_core.egg-info → kento_core-1.6.3.dev0}/PKG-INFO +1 -1
  2. {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/pyproject.toml +1 -1
  3. {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/src/kento/__init__.py +146 -1
  4. kento_core-1.6.3.dev0/src/kento/_diagnosis.py +384 -0
  5. kento_core-1.6.3.dev0/src/kento/_images.py +1467 -0
  6. kento_core-1.6.3.dev0/src/kento/_instances.py +3417 -0
  7. kento_core-1.6.3.dev0/src/kento/_network.py +433 -0
  8. kento_core-1.6.3.dev0/src/kento/_platform.py +187 -0
  9. kento_core-1.6.3.dev0/src/kento/_references.py +673 -0
  10. kento_core-1.6.3.dev0/src/kento/_storage.py +65 -0
  11. {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/src/kento/create.py +201 -36
  12. {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/src/kento/errors.py +13 -0
  13. kento_core-1.6.3.dev0/src/kento/exec_cmd.py +118 -0
  14. {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/src/kento/hook.sh +192 -71
  15. {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/src/kento/images.py +20 -57
  16. {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/src/kento/info.py +10 -1
  17. kento_core-1.6.3.dev0/src/kento/portfwd.py +393 -0
  18. {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/src/kento/pve.py +9 -4
  19. {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/src/kento/set_cmd.py +33 -10
  20. {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/src/kento/stop.py +14 -1
  21. {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/src/kento/suspend.py +32 -0
  22. {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/src/kento/vm.py +84 -29
  23. {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/src/kento/vm_hook.py +20 -1
  24. {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0/src/kento_core.egg-info}/PKG-INFO +1 -1
  25. {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/src/kento_core.egg-info/SOURCES.txt +20 -0
  26. {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/tests/test_create.py +264 -1
  27. {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/tests/test_create_locking.py +1 -1
  28. kento_core-1.6.3.dev0/tests/test_diagnose_module.py +210 -0
  29. kento_core-1.6.3.dev0/tests/test_diagnosis.py +552 -0
  30. kento_core-1.6.3.dev0/tests/test_exec.py +229 -0
  31. kento_core-1.6.3.dev0/tests/test_hold.py +263 -0
  32. {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/tests/test_hook.py +33 -17
  33. kento_core-1.6.3.dev0/tests/test_image_ops.py +745 -0
  34. kento_core-1.6.3.dev0/tests/test_image_record.py +498 -0
  35. {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/tests/test_images.py +15 -79
  36. kento_core-1.6.3.dev0/tests/test_images_types.py +560 -0
  37. {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/tests/test_init.py +22 -0
  38. kento_core-1.6.3.dev0/tests/test_instances.py +2792 -0
  39. kento_core-1.6.3.dev0/tests/test_network.py +387 -0
  40. kento_core-1.6.3.dev0/tests/test_platform.py +201 -0
  41. kento_core-1.6.3.dev0/tests/test_portfwd.py +193 -0
  42. {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/tests/test_pve.py +16 -0
  43. kento_core-1.6.3.dev0/tests/test_references.py +606 -0
  44. {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/tests/test_set.py +89 -2
  45. {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/tests/test_stop.py +20 -0
  46. kento_core-1.6.3.dev0/tests/test_storage.py +48 -0
  47. {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/tests/test_suspend.py +45 -1
  48. {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/tests/test_vm.py +92 -0
  49. {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/tests/test_vm_hook.py +45 -0
  50. kento_core-1.6.0.dev3/src/kento/exec_cmd.py +0 -50
  51. kento_core-1.6.0.dev3/tests/test_exec.py +0 -128
  52. {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/LICENSE.md +0 -0
  53. {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/README.md +0 -0
  54. {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/setup.cfg +0 -0
  55. {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/src/kento/attach.py +0 -0
  56. {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/src/kento/cloudinit.py +0 -0
  57. {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/src/kento/defaults.py +0 -0
  58. {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/src/kento/destroy.py +0 -0
  59. {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/src/kento/diagnose.py +0 -0
  60. {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/src/kento/hook.py +0 -0
  61. {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/src/kento/inject.py +0 -0
  62. {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/src/kento/inject.sh +0 -0
  63. {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/src/kento/layers.py +0 -0
  64. {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/src/kento/list.py +0 -0
  65. {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/src/kento/locking.py +0 -0
  66. {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/src/kento/logs.py +0 -0
  67. {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/src/kento/lxc_hook.py +0 -0
  68. {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/src/kento/reconcile.py +0 -0
  69. {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/src/kento/reset.py +0 -0
  70. {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/src/kento/start.py +0 -0
  71. {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/src/kento/subprocess_util.py +0 -0
  72. {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/src/kento_core.egg-info/dependency_links.txt +0 -0
  73. {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/src/kento_core.egg-info/top_level.txt +0 -0
  74. {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/tests/test_adopt.py +0 -0
  75. {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/tests/test_attach.py +0 -0
  76. {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/tests/test_cloudinit.py +0 -0
  77. {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/tests/test_create_passthrough.py +0 -0
  78. {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/tests/test_defaults.py +0 -0
  79. {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/tests/test_destroy.py +0 -0
  80. {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/tests/test_diagnose.py +0 -0
  81. {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/tests/test_errors.py +0 -0
  82. {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/tests/test_image_hold.py +0 -0
  83. {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/tests/test_info.py +0 -0
  84. {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/tests/test_inject.py +0 -0
  85. {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/tests/test_layers.py +0 -0
  86. {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/tests/test_list.py +0 -0
  87. {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/tests/test_locking.py +0 -0
  88. {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/tests/test_logs.py +0 -0
  89. {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/tests/test_lxc_hook.py +0 -0
  90. {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/tests/test_reconcile.py +0 -0
  91. {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/tests/test_reset.py +0 -0
  92. {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/tests/test_run_or_die_integration.py +0 -0
  93. {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/tests/test_start.py +0 -0
  94. {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/tests/test_subprocess_util.py +0 -0
  95. {kento_core-1.6.0.dev3 → kento_core-1.6.3.dev0}/tests/test_validate_name.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: kento-core
3
- Version: 1.6.0.dev3
3
+ Version: 1.6.3.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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "kento-core"
7
- version = "1.6.0.dev3"
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))