feat(webui): add read-only console restart status and impact controls (Closes #667)
This commit is contained in:
@@ -0,0 +1,579 @@
|
||||
"""Read-only restart status, impact preview, and approval state (#667).
|
||||
|
||||
Phase 1 of the console restart surface. It *consumes* the #655 coordinator
|
||||
substrate and renders it; it never restarts, reloads, drains, approves, or kills
|
||||
anything. There is no apply path in this module, so there is no execution gate
|
||||
here to arm incorrectly — the only writes the console could perform are the ones
|
||||
it does not implement.
|
||||
|
||||
Sources, each independently fail-soft and each reported with its own
|
||||
:class:`SourceStatus`:
|
||||
|
||||
* :mod:`restart_coordinator` — restart-class policy matrix (#663) and the
|
||||
blast-radius impact report (#658).
|
||||
* :mod:`drain_proof` — drain checklist and gate verdict (#661), verified
|
||||
read-only against a caller-supplied proof.
|
||||
* :mod:`post_restart_reconcile` — post-restart completion proof (#662).
|
||||
* :mod:`webui.console_authz` — role authorization for the approval controls
|
||||
(#633).
|
||||
|
||||
Three rules this module holds itself to, because a status surface that lies is
|
||||
worse than one that is absent:
|
||||
|
||||
**A source that could not be read is reported unavailable, never green.** No
|
||||
default, placeholder, or self-comparison is substituted for a reading that
|
||||
failed. An unreadable control-plane DB yields ``inventory_complete=False``,
|
||||
which the coordinator itself turns into a fail-closed verdict.
|
||||
|
||||
**Authorization is asked the way execution would ask it.** Every authorization
|
||||
probe passes ``for_execution=True``, so the console reports whether the action
|
||||
could actually run rather than the weaker "this principal is the right role".
|
||||
While the console is in Phase 1 that answer is ``phase_not_active`` for every
|
||||
phase-2 action, and the surface says so plainly instead of showing an allow.
|
||||
|
||||
**The database is opened read-only.** ``ControlPlaneDB()`` creates directories
|
||||
and runs migrations on construction, which is a write; this module opens the
|
||||
sqlite file with ``mode=ro`` exactly as :mod:`webui.inventory` does, and treats
|
||||
a missing file as missing authority rather than an empty inventory.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sqlite3
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Callable, Mapping
|
||||
|
||||
import control_plane_db
|
||||
import drain_proof
|
||||
import restart_coordinator
|
||||
from webui import console_authz
|
||||
from webui.inventory import redact_path, scrub
|
||||
|
||||
# --- Source status ----------------------------------------------------------
|
||||
|
||||
STATUS_OK = "ok"
|
||||
STATUS_UNAVAILABLE = "unavailable"
|
||||
|
||||
#: Console actions whose authorization state this surface reports. Both are
|
||||
#: pre-existing #642 actions; this module adds no new console action because it
|
||||
#: performs no console action.
|
||||
REPORTED_ACTIONS: tuple[str, ...] = (
|
||||
"system.restart_namespace",
|
||||
"system.reload_namespace",
|
||||
)
|
||||
|
||||
#: The break-glass workflow (#664) is not consumed here. It is declared so the
|
||||
#: surface is honest about the gap rather than silently omitting a governance
|
||||
#: path the operator has been told exists.
|
||||
BREAK_GLASS_ISSUE = 664
|
||||
BREAK_GLASS_PENDING_REASON = (
|
||||
"The break-glass workflow (#664) is not yet available on this branch's "
|
||||
"base; no break-glass control is offered and none is implied."
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SourceStatus:
|
||||
"""Whether one backing source could be read, and why not when it could not."""
|
||||
|
||||
name: str
|
||||
status: str
|
||||
detail: str = ""
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
return self.status == STATUS_OK
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"name": self.name,
|
||||
"status": self.status,
|
||||
"available": self.available,
|
||||
"detail": self.detail,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RestartClassView:
|
||||
"""One row of the #663 restart-class matrix, scoped to the viewer's role."""
|
||||
|
||||
restart_class: str
|
||||
required_permission: str
|
||||
expected_blast_radius: str
|
||||
drain_requirement: str
|
||||
full_drain_required: bool
|
||||
approval_requirement: str
|
||||
request_roles: tuple[str, ...]
|
||||
execution_roles: tuple[str, ...]
|
||||
viewer_may_request: bool
|
||||
viewer_may_execute: bool
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"restart_class": self.restart_class,
|
||||
"required_permission": self.required_permission,
|
||||
"expected_blast_radius": self.expected_blast_radius,
|
||||
"drain_requirement": self.drain_requirement,
|
||||
"full_drain_required": self.full_drain_required,
|
||||
"approval_requirement": self.approval_requirement,
|
||||
"request_roles": list(self.request_roles),
|
||||
"execution_roles": list(self.execution_roles),
|
||||
"viewer_may_request": self.viewer_may_request,
|
||||
"viewer_may_execute": self.viewer_may_execute,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ActionAuthorization:
|
||||
"""Authorization state for one console action, asked as execution would."""
|
||||
|
||||
action_id: str
|
||||
summary: str
|
||||
required_role: str
|
||||
allowed: bool
|
||||
execution_enabled: bool
|
||||
reason_code: str
|
||||
detail: str
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"action_id": self.action_id,
|
||||
"summary": self.summary,
|
||||
"required_role": self.required_role,
|
||||
"allowed": self.allowed,
|
||||
"execution_enabled": self.execution_enabled,
|
||||
"reason_code": self.reason_code,
|
||||
"detail": self.detail,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BreakGlassSurface:
|
||||
"""Declared-but-unavailable break-glass panel (#664 is not on this base)."""
|
||||
|
||||
available: bool
|
||||
issue: int
|
||||
reason: str
|
||||
viewer_is_privileged: bool
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"available": self.available,
|
||||
"issue": self.issue,
|
||||
"reason": self.reason,
|
||||
"viewer_is_privileged": self.viewer_is_privileged,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RestartConsoleSnapshot:
|
||||
"""Everything the read-only restart console renders."""
|
||||
|
||||
generated_at: str
|
||||
viewer_role: str
|
||||
viewer_authenticated: bool
|
||||
read_only: bool
|
||||
impact: dict[str, Any] | None
|
||||
impact_source: SourceStatus
|
||||
drain: dict[str, Any] | None
|
||||
drain_source: SourceStatus
|
||||
reconcile: dict[str, Any] | None
|
||||
reconcile_source: SourceStatus
|
||||
restart_classes: tuple[RestartClassView, ...]
|
||||
authorizations: tuple[ActionAuthorization, ...]
|
||||
break_glass: BreakGlassSurface
|
||||
notes: tuple[str, ...] = field(default_factory=tuple)
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"generated_at": self.generated_at,
|
||||
"viewer_role": self.viewer_role,
|
||||
"viewer_authenticated": self.viewer_authenticated,
|
||||
"read_only": self.read_only,
|
||||
"impact": self.impact,
|
||||
"impact_source": self.impact_source.as_dict(),
|
||||
"drain": self.drain,
|
||||
"drain_source": self.drain_source.as_dict(),
|
||||
"reconcile": self.reconcile,
|
||||
"reconcile_source": self.reconcile_source.as_dict(),
|
||||
"restart_classes": [c.as_dict() for c in self.restart_classes],
|
||||
"authorizations": [a.as_dict() for a in self.authorizations],
|
||||
"break_glass": self.break_glass.as_dict(),
|
||||
"notes": list(self.notes),
|
||||
"links": {
|
||||
"issue": 667,
|
||||
"extends": 642,
|
||||
"umbrella": 655,
|
||||
"coordinator": 658,
|
||||
"drain_proof": 661,
|
||||
"reconcile": 662,
|
||||
"restart_classes": 663,
|
||||
"break_glass": BREAK_GLASS_ISSUE,
|
||||
"vision": 652,
|
||||
"roadmap": 653,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _utc_now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
# --- Control-plane inventory (read-only) ------------------------------------
|
||||
|
||||
|
||||
def read_control_plane_inventory(
|
||||
*,
|
||||
db_path: str | None = None,
|
||||
limit: int = 200,
|
||||
) -> dict[str, Any]:
|
||||
"""Read sessions and leases for an impact evaluation, read-only.
|
||||
|
||||
Returns the inventory mapping
|
||||
:func:`restart_coordinator.evaluate_restart_impact` expects.
|
||||
``inventory_complete`` is True only when every read succeeded, so a partial
|
||||
read denies rather than under-reporting the blast radius.
|
||||
|
||||
The database is never created, migrated, or written: a missing file means
|
||||
the console has no session authority, which is not the same as there being
|
||||
no sessions.
|
||||
"""
|
||||
|
||||
path = (db_path or control_plane_db.default_db_path() or "").strip()
|
||||
incomplete: list[str] = []
|
||||
|
||||
def _incomplete(reason: str) -> dict[str, Any]:
|
||||
return {
|
||||
"sessions": [],
|
||||
"leases": [],
|
||||
"terminal_lock": None,
|
||||
"prior_recovery_attempts": [],
|
||||
"inventory_complete": False,
|
||||
"incomplete_reasons": [reason],
|
||||
}
|
||||
|
||||
if not path:
|
||||
return _incomplete("control-plane database path is not configured")
|
||||
if not os.path.exists(path):
|
||||
return _incomplete(
|
||||
f"control-plane database not present at {redact_path(path)}; "
|
||||
"no session or lease authority available"
|
||||
)
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True, timeout=5)
|
||||
conn.row_factory = sqlite3.Row
|
||||
except sqlite3.Error as exc:
|
||||
return _incomplete(f"control-plane database could not be opened: {exc}")
|
||||
|
||||
sessions: list[dict[str, Any]] = []
|
||||
leases: list[dict[str, Any]] = []
|
||||
capped = max(1, int(limit))
|
||||
try:
|
||||
tables = {
|
||||
str(row[0])
|
||||
for row in conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type = 'table'"
|
||||
).fetchall()
|
||||
}
|
||||
if "sessions" not in tables:
|
||||
incomplete.append("control-plane database has no sessions table")
|
||||
else:
|
||||
sessions = [
|
||||
dict(row)
|
||||
for row in conn.execute(
|
||||
"SELECT session_id, role, profile, pid, status,"
|
||||
" last_heartbeat_at FROM sessions"
|
||||
" WHERE status = 'active'"
|
||||
" ORDER BY last_heartbeat_at DESC LIMIT ?",
|
||||
(capped,),
|
||||
).fetchall()
|
||||
]
|
||||
|
||||
if "leases" not in tables:
|
||||
incomplete.append("control-plane database has no leases table")
|
||||
elif "work_items" not in tables:
|
||||
incomplete.append(
|
||||
"control-plane database has no work_items table; lease work "
|
||||
"identity cannot be resolved"
|
||||
)
|
||||
else:
|
||||
leases = [
|
||||
dict(row)
|
||||
for row in conn.execute(
|
||||
"SELECT l.lease_id, l.session_id, l.role, l.phase,"
|
||||
" l.status AS freshness, l.worktree_path,"
|
||||
" w.kind AS work_kind, w.number AS work_number"
|
||||
" FROM leases l"
|
||||
" JOIN work_items w ON w.work_item_id = l.work_item_id"
|
||||
" WHERE l.status = 'active'"
|
||||
" ORDER BY l.expires_at DESC LIMIT ?",
|
||||
(capped,),
|
||||
).fetchall()
|
||||
]
|
||||
except sqlite3.Error as exc:
|
||||
return _incomplete(f"control-plane database read failed: {exc}")
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return {
|
||||
"sessions": sessions,
|
||||
"leases": leases,
|
||||
"terminal_lock": None,
|
||||
"prior_recovery_attempts": [],
|
||||
"inventory_complete": not incomplete,
|
||||
"incomplete_reasons": incomplete,
|
||||
}
|
||||
|
||||
|
||||
# --- Composition ------------------------------------------------------------
|
||||
|
||||
|
||||
def build_restart_class_views(viewer_role: str | None) -> tuple[RestartClassView, ...]:
|
||||
"""Render the #663 class matrix, marking what this viewer may request."""
|
||||
|
||||
normalized = str(viewer_role or "").strip().lower()
|
||||
views: list[RestartClassView] = []
|
||||
for policy in restart_coordinator.RESTART_CLASS_POLICIES.values():
|
||||
views.append(
|
||||
RestartClassView(
|
||||
restart_class=policy.restart_class.value,
|
||||
required_permission=policy.required_permission,
|
||||
expected_blast_radius=policy.expected_blast_radius,
|
||||
drain_requirement=policy.drain_requirement,
|
||||
full_drain_required=policy.full_drain_required,
|
||||
approval_requirement=policy.approval_requirement,
|
||||
request_roles=tuple(policy.request_roles),
|
||||
execution_roles=tuple(policy.execution_roles),
|
||||
viewer_may_request=normalized in policy.request_roles,
|
||||
viewer_may_execute=normalized in policy.execution_roles,
|
||||
)
|
||||
)
|
||||
return tuple(views)
|
||||
|
||||
|
||||
def build_action_authorizations(
|
||||
principal: console_authz.Principal | None,
|
||||
) -> tuple[ActionAuthorization, ...]:
|
||||
"""Authorization state for the approval controls, asked as execution.
|
||||
|
||||
``for_execution=True`` is deliberate. Asking without it answers "is this
|
||||
principal senior enough", which is not the question an operator looking at a
|
||||
control needs answered; asking with it answers "would this run", and while
|
||||
the console is in Phase 1 the honest answer is no.
|
||||
"""
|
||||
|
||||
results: list[ActionAuthorization] = []
|
||||
for action_id in REPORTED_ACTIONS:
|
||||
action = console_authz.get_action(action_id)
|
||||
decision = console_authz.authorize(action_id, principal, for_execution=True)
|
||||
results.append(
|
||||
ActionAuthorization(
|
||||
action_id=action_id,
|
||||
summary=action.summary if action else "",
|
||||
required_role=(
|
||||
action.minimum_role if action else console_authz.OPERATOR
|
||||
),
|
||||
allowed=bool(decision.allowed),
|
||||
execution_enabled=bool(decision.execution_enabled),
|
||||
reason_code=str(decision.reason_code or ""),
|
||||
detail=str(decision.detail or ""),
|
||||
)
|
||||
)
|
||||
return tuple(results)
|
||||
|
||||
|
||||
def viewer_is_privileged(principal: console_authz.Principal | None) -> bool:
|
||||
"""True when the viewer holds at least the operator role."""
|
||||
|
||||
who = principal if principal is not None else console_authz.ANONYMOUS
|
||||
if not who.authenticated:
|
||||
return False
|
||||
return who.rank >= console_authz.ROLE_ORDER.index(console_authz.OPERATOR)
|
||||
|
||||
|
||||
def load_impact_report(
|
||||
*,
|
||||
principal: console_authz.Principal | None = None,
|
||||
restart_class: str = restart_coordinator.RestartClass.FULL_MCP_RESTART.value,
|
||||
db_path: str | None = None,
|
||||
limit: int = 200,
|
||||
read_inventory: Callable[..., Mapping[str, Any]] | None = None,
|
||||
now: datetime | None = None,
|
||||
) -> tuple[dict[str, Any] | None, SourceStatus]:
|
||||
"""Evaluate the blast radius for *restart_class*, always dry-run."""
|
||||
|
||||
reader = read_inventory or read_control_plane_inventory
|
||||
try:
|
||||
inventory = dict(reader(db_path=db_path, limit=limit))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return None, SourceStatus(
|
||||
"impact",
|
||||
STATUS_UNAVAILABLE,
|
||||
f"control-plane inventory failed: {type(exc).__name__}: {exc}",
|
||||
)
|
||||
|
||||
who = principal if principal is not None else console_authz.ANONYMOUS
|
||||
viewer_role = str(who.role or "").strip().lower()
|
||||
try:
|
||||
report = restart_coordinator.evaluate_restart_impact(
|
||||
inventory,
|
||||
now=now,
|
||||
dry_run=True,
|
||||
restart_class=restart_class,
|
||||
requester_role=viewer_role,
|
||||
requester_permissions=restart_coordinator.permissions_for_role(
|
||||
viewer_role
|
||||
),
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return None, SourceStatus(
|
||||
"impact",
|
||||
STATUS_UNAVAILABLE,
|
||||
f"impact evaluation failed: {type(exc).__name__}: {exc}",
|
||||
)
|
||||
|
||||
payload = scrub(report.as_dict())
|
||||
detail = ""
|
||||
if not report.inventory_complete:
|
||||
detail = "; ".join(report.incomplete_reasons) or "inventory incomplete"
|
||||
return payload, SourceStatus("impact", STATUS_OK, detail)
|
||||
|
||||
|
||||
def load_drain_status(
|
||||
*,
|
||||
proof: Mapping[str, Any] | None = None,
|
||||
now: datetime | None = None,
|
||||
expected_impact_fingerprint: str | None = None,
|
||||
) -> tuple[dict[str, Any] | None, SourceStatus]:
|
||||
"""Verify a supplied drain proof read-only and report the verdict.
|
||||
|
||||
No proof supplied is not a failure and not a pass: it is reported as the
|
||||
absence of a proof, which is exactly what the #661 gate would deny on.
|
||||
"""
|
||||
|
||||
if proof is None:
|
||||
return None, SourceStatus(
|
||||
"drain",
|
||||
STATUS_UNAVAILABLE,
|
||||
"no drain proof supplied; the #661 gate denies a restart without a "
|
||||
"valid unexpired clean proof",
|
||||
)
|
||||
try:
|
||||
verified = drain_proof.verify_drain_proof(
|
||||
proof,
|
||||
now=now,
|
||||
expected_impact_fingerprint=expected_impact_fingerprint,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return None, SourceStatus(
|
||||
"drain",
|
||||
STATUS_UNAVAILABLE,
|
||||
f"drain proof verification failed: {type(exc).__name__}: {exc}",
|
||||
)
|
||||
return scrub(verified.as_dict()), SourceStatus("drain", STATUS_OK)
|
||||
|
||||
|
||||
def load_reconcile_status(
|
||||
*,
|
||||
load_proof: Callable[[], Any] | None = None,
|
||||
) -> tuple[dict[str, Any] | None, SourceStatus]:
|
||||
"""Report the most recent post-restart completion proof (#662)."""
|
||||
|
||||
if load_proof is None:
|
||||
return None, SourceStatus(
|
||||
"reconcile",
|
||||
STATUS_UNAVAILABLE,
|
||||
"no post-restart completion proof source is wired into this view",
|
||||
)
|
||||
try:
|
||||
proof = load_proof()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
return None, SourceStatus(
|
||||
"reconcile",
|
||||
STATUS_UNAVAILABLE,
|
||||
f"reconcile proof unavailable: {type(exc).__name__}: {exc}",
|
||||
)
|
||||
if proof is None:
|
||||
return None, SourceStatus(
|
||||
"reconcile",
|
||||
STATUS_UNAVAILABLE,
|
||||
"no post-restart reconcile has been recorded",
|
||||
)
|
||||
payload = proof.as_dict() if hasattr(proof, "as_dict") else dict(proof)
|
||||
return scrub(payload), SourceStatus("reconcile", STATUS_OK)
|
||||
|
||||
|
||||
def load_restart_console_snapshot(
|
||||
*,
|
||||
principal: console_authz.Principal | None = None,
|
||||
restart_class: str = restart_coordinator.RestartClass.FULL_MCP_RESTART.value,
|
||||
db_path: str | None = None,
|
||||
limit: int = 200,
|
||||
drain_proof_payload: Mapping[str, Any] | None = None,
|
||||
read_inventory: Callable[..., Mapping[str, Any]] | None = None,
|
||||
load_reconcile_proof: Callable[[], Any] | None = None,
|
||||
now: datetime | None = None,
|
||||
) -> RestartConsoleSnapshot:
|
||||
"""Compose the read-only restart console snapshot."""
|
||||
|
||||
who = principal if principal is not None else console_authz.ANONYMOUS
|
||||
moment = now or _utc_now()
|
||||
|
||||
impact, impact_source = load_impact_report(
|
||||
principal=who,
|
||||
restart_class=restart_class,
|
||||
db_path=db_path,
|
||||
limit=limit,
|
||||
read_inventory=read_inventory,
|
||||
now=moment,
|
||||
)
|
||||
fingerprint = None
|
||||
if impact is not None:
|
||||
try:
|
||||
fingerprint = drain_proof.impact_fingerprint(impact)
|
||||
except Exception: # noqa: BLE001
|
||||
fingerprint = None
|
||||
|
||||
drain, drain_source = load_drain_status(
|
||||
proof=drain_proof_payload,
|
||||
now=moment,
|
||||
expected_impact_fingerprint=fingerprint,
|
||||
)
|
||||
reconcile, reconcile_source = load_reconcile_status(
|
||||
load_proof=load_reconcile_proof
|
||||
)
|
||||
|
||||
notes: list[str] = [
|
||||
"This surface is read-only: it evaluates and displays, and performs no "
|
||||
"restart, reload, drain, approval, or process action.",
|
||||
]
|
||||
if not impact_source.available:
|
||||
notes.append(
|
||||
"Impact preview unavailable — a restart decision must not be made "
|
||||
"from this page while the blast radius is unknown."
|
||||
)
|
||||
|
||||
return RestartConsoleSnapshot(
|
||||
generated_at=moment.isoformat(),
|
||||
viewer_role=str(who.role or "anonymous"),
|
||||
viewer_authenticated=bool(who.authenticated),
|
||||
read_only=True,
|
||||
impact=impact,
|
||||
impact_source=impact_source,
|
||||
drain=drain,
|
||||
drain_source=drain_source,
|
||||
reconcile=reconcile,
|
||||
reconcile_source=reconcile_source,
|
||||
restart_classes=build_restart_class_views(who.role),
|
||||
authorizations=build_action_authorizations(who),
|
||||
break_glass=BreakGlassSurface(
|
||||
available=False,
|
||||
issue=BREAK_GLASS_ISSUE,
|
||||
reason=BREAK_GLASS_PENDING_REASON,
|
||||
viewer_is_privileged=viewer_is_privileged(who),
|
||||
),
|
||||
notes=tuple(notes),
|
||||
)
|
||||
Reference in New Issue
Block a user