116 lines
3.7 KiB
Python
116 lines
3.7 KiB
Python
|
|
"""Decision integration.
|
||
|
|
|
||
|
|
Privileged actions require an approved decision (or approved CCR) unless the lane
|
||
|
|
is explicitly `bootstrap-only` or the caller passes --dry-run. Decisions are
|
||
|
|
looked up from State Hub by id; when the hub has no record yet (common during the
|
||
|
|
pilot) a local approval fixture under `.decisions/<ref>.yaml` can stand in, so the
|
||
|
|
end-to-end chain is testable before the canonical hub decision object exists.
|
||
|
|
|
||
|
|
No secret values are ever read from or written to a decision.
|
||
|
|
"""
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import json
|
||
|
|
import urllib.error
|
||
|
|
import urllib.request
|
||
|
|
from dataclasses import dataclass
|
||
|
|
from pathlib import Path
|
||
|
|
from typing import Any
|
||
|
|
|
||
|
|
import yaml
|
||
|
|
|
||
|
|
from secrets_engine.catalog import CatalogEntry
|
||
|
|
from secrets_engine.errors import DecisionError
|
||
|
|
|
||
|
|
APPROVED_STATUSES = {"resolved", "approved", "accepted"}
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass
|
||
|
|
class Decision:
|
||
|
|
id: str
|
||
|
|
title: str
|
||
|
|
status: str
|
||
|
|
superseded_by: str | None
|
||
|
|
source: str # "hub" | "local-fixture"
|
||
|
|
review_url: str = ""
|
||
|
|
raw: dict[str, Any] | None = None
|
||
|
|
|
||
|
|
def is_approved(self) -> bool:
|
||
|
|
return self.status.lower() in APPROVED_STATUSES and not self.superseded_by
|
||
|
|
|
||
|
|
|
||
|
|
def _hub_get(hub_url: str, decision_id: str) -> dict[str, Any] | None:
|
||
|
|
url = hub_url.rstrip("/") + f"/decisions/{decision_id}"
|
||
|
|
try:
|
||
|
|
with urllib.request.urlopen(url, timeout=3) as resp:
|
||
|
|
return json.loads(resp.read())
|
||
|
|
except (urllib.error.URLError, OSError, ValueError):
|
||
|
|
return None
|
||
|
|
|
||
|
|
|
||
|
|
def _local_fixture(repo_root: Path, ref: str) -> dict[str, Any] | None:
|
||
|
|
path = repo_root / ".decisions" / f"{ref}.yaml"
|
||
|
|
if not path.exists():
|
||
|
|
return None
|
||
|
|
try:
|
||
|
|
return yaml.safe_load(path.read_text(encoding="utf-8")) or None
|
||
|
|
except yaml.YAMLError as e:
|
||
|
|
raise DecisionError(f"{path}: invalid decision fixture: {e}") from e
|
||
|
|
|
||
|
|
|
||
|
|
def resolve_decision(
|
||
|
|
*,
|
||
|
|
hub_url: str,
|
||
|
|
repo_root: Path,
|
||
|
|
decision_ref: str,
|
||
|
|
) -> Decision:
|
||
|
|
"""Resolve a decision by id (hub) or slug (local fixture). Raises if absent."""
|
||
|
|
if not decision_ref:
|
||
|
|
raise DecisionError("no decision reference provided")
|
||
|
|
|
||
|
|
doc = _hub_get(hub_url, decision_ref)
|
||
|
|
if doc:
|
||
|
|
return Decision(
|
||
|
|
id=doc.get("id", decision_ref),
|
||
|
|
title=doc.get("title", ""),
|
||
|
|
status=doc.get("status", "unknown"),
|
||
|
|
superseded_by=doc.get("superseded_by"),
|
||
|
|
source="hub",
|
||
|
|
review_url=f"{hub_url.rstrip('/')}/decisions/{doc.get('id', decision_ref)}",
|
||
|
|
raw=doc,
|
||
|
|
)
|
||
|
|
|
||
|
|
fixture = _local_fixture(repo_root, decision_ref)
|
||
|
|
if fixture:
|
||
|
|
return Decision(
|
||
|
|
id=fixture.get("id", decision_ref),
|
||
|
|
title=fixture.get("title", decision_ref),
|
||
|
|
status=fixture.get("status", "unknown"),
|
||
|
|
superseded_by=fixture.get("superseded_by"),
|
||
|
|
source="local-fixture",
|
||
|
|
review_url=fixture.get("review_url", ""),
|
||
|
|
raw=fixture,
|
||
|
|
)
|
||
|
|
|
||
|
|
raise DecisionError(
|
||
|
|
f"decision '{decision_ref}' not found in State Hub or local fixtures"
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
def require_approved(entry: CatalogEntry, decision: Decision | None) -> None:
|
||
|
|
"""Enforce the lane's approval model. Raises DecisionError if not satisfied."""
|
||
|
|
if not entry.approval_required():
|
||
|
|
return # bootstrap-only lane
|
||
|
|
if decision is None:
|
||
|
|
raise DecisionError(
|
||
|
|
f"lane '{entry.id}' requires an approved decision; none resolved"
|
||
|
|
)
|
||
|
|
if decision.superseded_by:
|
||
|
|
raise DecisionError(
|
||
|
|
f"decision '{decision.id}' is superseded by '{decision.superseded_by}'"
|
||
|
|
)
|
||
|
|
if not decision.is_approved():
|
||
|
|
raise DecisionError(
|
||
|
|
f"decision '{decision.id}' is not approved (status='{decision.status}')"
|
||
|
|
)
|