feat(P1): kyverno-json engine core + PolicyEngine protocol (REQ-291..294, 308, 309)
core/policy_engine.py: PolicyEngine Protocol (PEP 544, runtime_checkable)
+ PolicyEngineRegistry (selects from config.json.policy.engine) + NullEngine
fallback (NULL_ENGINE_INACTIVE when policy key absent).
adapters/kyverno-json/: KyvernoJsonEngine — shells to , translates
native output → list[dict] PCR records (engine: "kyverno", ruleId KJ_ prefix,
severity via nova.cloudinit.dev/severity annotation, default info).
is_configured() guards on → KJ_ENGINE_NOT_CONFIGURED SKIPPED PCR
(distinct from NullEngine). Defensive parsing (malformed → error PCR).
config.json: new object {engine: kyverno-json, policy_root}.
scripts/install-kyverno-json.sh: go install kj@latest (D-115).
CI (.gitea + .github): install Go + kj for policy-engine tests (best-effort;
tests skip when kj absent).
tests: 24 pass, 2 skip (kj not installed). 132 existing tests unchanged.
NullEngine satisfies PolicyEngine Protocol (G-Q8a — proves swap boundary).
---ci---
project: acdl
phase: 1
milestone: v1.25
status: execute
phase_role: execution
requirements:
covered: [REQ-291, REQ-292, REQ-293, REQ-294, REQ-308, REQ-309]
partial: []
---/ci---
This commit is contained in:
@@ -0,0 +1,212 @@
|
||||
"""Nova Policy Engine Registry (REQ-291, v1.25).
|
||||
|
||||
The swappable policy-engine abstraction. A Python Protocol (PEP 544)
|
||||
defines the engine contract; a registry selects the active engine from
|
||||
``config.json``'s ``policy.engine`` key. This is the **swap boundary**
|
||||
(ARCHITECTURE.md §12.7) — the confidence signal and pipeline never
|
||||
import an engine directly; they go through the registry. A future
|
||||
``OpaEngine`` implements the same protocol without touching the
|
||||
confidence signal, the PCR schema, or the pipeline.
|
||||
|
||||
The protocol is minimal (3 members) by design:
|
||||
|
||||
- ``name`` — the engine's registry key (matches ``config.json.policy.engine``).
|
||||
- ``is_configured()`` — returns False when the engine's binary is absent
|
||||
(the registry's caller must skip gracefully, emitting SKIPPED PCRs).
|
||||
- ``evaluate(payload, policy_dir, contract_id)`` — runs the engine's
|
||||
policies over ``payload`` and returns a ``list[dict]`` where each dict
|
||||
conforms to ``schemas/policy_check_result.schema.json``.
|
||||
|
||||
A ``NullEngine`` is the fallback when the ``policy`` key is absent from
|
||||
``config.json`` (backward compatibility for tests that don't set the
|
||||
key — it emits a single SKIPPED PCR so the confidence signal proceeds
|
||||
with a neutral ``policy`` input).
|
||||
|
||||
Engine enum reuse (D-116): kyverno-json PCR records carry
|
||||
``engine: "kyverno"`` (no new enum value). The ``engine`` field records
|
||||
the policy-engine *family*, not the specific binary. The K8s Kyverno
|
||||
adapter and the kyverno-json engine are distinguished by ``ruleId``
|
||||
prefix (``KYVERNO_`` vs ``KJ_``).
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Protocol, Union, runtime_checkable
|
||||
|
||||
import datetime
|
||||
|
||||
|
||||
def _iso8601_now() -> str:
|
||||
return datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
Payload = Union[dict, list, str]
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class PolicyEngine(Protocol):
|
||||
"""The swap boundary for policy engines.
|
||||
|
||||
Implementations: ``KyvernoJsonEngine`` (adapters/kyverno-json/),
|
||||
``NullEngine`` (this module), future ``OpaEngine``.
|
||||
"""
|
||||
|
||||
@property
|
||||
def name(self) -> str: ...
|
||||
|
||||
def is_configured(self) -> bool: ...
|
||||
|
||||
def evaluate(self, payload: Payload, policy_dir: Path,
|
||||
contract_id: str) -> list[dict]: ...
|
||||
|
||||
|
||||
def _skipped_pcr(rule_id: str, message: str, contract_id: str) -> dict:
|
||||
return {
|
||||
"contractId": contract_id,
|
||||
"evaluatedAt": _iso8601_now(),
|
||||
"engine": "kyverno",
|
||||
"ruleId": rule_id,
|
||||
"severity": "info",
|
||||
"result": "skipped",
|
||||
"message": message,
|
||||
"evidence": {},
|
||||
"resourceRef": "",
|
||||
}
|
||||
|
||||
|
||||
class NullEngine:
|
||||
"""Fallback when ``config.json.policy`` is absent.
|
||||
|
||||
Emits a single SKIPPED PCR with ``ruleId: NULL_ENGINE_INACTIVE`` so
|
||||
the confidence signal's ``policy`` input is non-null (the per-input
|
||||
score for a single SKIPPED PCR is 1.0 — skipped counts as pass per
|
||||
``core/confidence_signal.py:84-89``). This keeps existing tests
|
||||
passing when the ``policy`` key is not set.
|
||||
"""
|
||||
|
||||
name = "null"
|
||||
|
||||
def is_configured(self) -> bool:
|
||||
return False
|
||||
|
||||
def evaluate(self, payload: Payload, policy_dir: Path,
|
||||
contract_id: str) -> list[dict]:
|
||||
return [_skipped_pcr(
|
||||
"NULL_ENGINE_INACTIVE",
|
||||
"NullEngine active — the `policy` key is absent from config.json. "
|
||||
"No policy engine is configured; the confidence signal proceeds with "
|
||||
"a neutral SKIPPED policy input.",
|
||||
contract_id,
|
||||
)]
|
||||
|
||||
|
||||
_REGISTRY: dict[str, Callable[[], PolicyEngine]] = {}
|
||||
|
||||
|
||||
def register(name: str, factory: Callable[[], PolicyEngine]) -> None:
|
||||
"""Register an engine factory under ``name``.
|
||||
|
||||
The factory is called lazily by ``get_engine()`` so an engine's
|
||||
binary dependency (e.g. ``kj``) is not required at import time.
|
||||
"""
|
||||
_REGISTRY[name] = factory
|
||||
|
||||
|
||||
def _load_config_policy() -> dict | None:
|
||||
"""Read the ``policy`` object from ``.ciagent/config.json``.
|
||||
|
||||
Returns ``None`` when the file is absent or the ``policy`` key is
|
||||
missing (the caller falls back to ``NullEngine``).
|
||||
"""
|
||||
repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
cfg = os.path.join(repo_root, ".ciagent", "config.json")
|
||||
if not os.path.isfile(cfg):
|
||||
return None
|
||||
try:
|
||||
with open(cfg, "r", encoding="utf-8") as fh:
|
||||
data = json.load(fh)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return None
|
||||
return data.get("policy")
|
||||
|
||||
|
||||
def get_engine() -> PolicyEngine:
|
||||
"""Return the active ``PolicyEngine`` from ``config.json``.
|
||||
|
||||
Reads ``config.json.policy.engine`` (default ``"kyverno-json"``).
|
||||
Falls back to ``NullEngine`` when the ``policy`` key is absent
|
||||
(backward compatibility). Raises ``KeyError`` for an unknown engine
|
||||
name (a typo in config — fail loud, not silent).
|
||||
"""
|
||||
policy_cfg = _load_config_policy()
|
||||
if policy_cfg is None:
|
||||
return NullEngine()
|
||||
engine_name = policy_cfg.get("engine", "kyverno-json")
|
||||
factory = _REGISTRY.get(engine_name)
|
||||
if factory is None:
|
||||
raise KeyError(
|
||||
f"Unknown policy engine '{engine_name}' in config.json. "
|
||||
f"Registered engines: {sorted(_REGISTRY.keys()) or ['(none)']}. "
|
||||
f"Set policy.engine to a registered name or install the engine adapter."
|
||||
)
|
||||
return factory()
|
||||
|
||||
|
||||
def get_policy_root() -> Path:
|
||||
"""Return the configured policy root directory (or a default)."""
|
||||
policy_cfg = _load_config_policy()
|
||||
if policy_cfg is None:
|
||||
return Path("adapters/kyverno-json/policies")
|
||||
root = policy_cfg.get("policy_root", "adapters/kyverno-json/policies")
|
||||
repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
if os.path.isabs(root):
|
||||
return Path(root)
|
||||
return Path(repo_root) / root
|
||||
|
||||
|
||||
def _register_builtin(name: str, factory: Callable[[], PolicyEngine]) -> None:
|
||||
register(name, factory)
|
||||
|
||||
|
||||
def _autoload_kyverno_json() -> None:
|
||||
"""Register the kyverno-json engine if its adapter is importable.
|
||||
|
||||
The adapter directory uses a hyphen (``adapters/kyverno-json/``),
|
||||
so a plain ``import`` is not possible. Load the module by file path
|
||||
via ``importlib.util``. Lazy import so ``core/policy_engine.py``
|
||||
does not require ``adapters/kyverno-json/`` at import time (the
|
||||
adapter imports ``yaml``, which may be unavailable in minimal test
|
||||
envs).
|
||||
"""
|
||||
try:
|
||||
import importlib.util
|
||||
repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
adapter_path = os.path.join(
|
||||
repo_root, "adapters", "kyverno-json", "kyverno_json_engine.py"
|
||||
)
|
||||
if not os.path.isfile(adapter_path):
|
||||
return
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"kyverno_json_engine", adapter_path
|
||||
)
|
||||
if spec is None or spec.loader is None:
|
||||
return
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
engine_cls = getattr(mod, "KyvernoJsonEngine")
|
||||
_register_builtin("kyverno-json", engine_cls)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
_autoload_kyverno_json()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
eng = get_engine()
|
||||
print(json.dumps({
|
||||
"engine": eng.name,
|
||||
"is_configured": eng.is_configured(),
|
||||
"policy_root": str(get_policy_root()),
|
||||
}, indent=2))
|
||||
Reference in New Issue
Block a user