580 lines
20 KiB
Python
580 lines
20 KiB
Python
"""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),
|
|
)
|