feat(drift): record remediation evidence
This commit is contained in:
@@ -57,6 +57,28 @@ class DriftFingerprintHandoffRequest(BaseModel):
|
||||
note: str | None = Field(default=None, max_length=500)
|
||||
|
||||
|
||||
class DriftFingerprintRemediationRequest(BaseModel):
|
||||
"""Record-only remediation request for a stable drift fingerprint."""
|
||||
|
||||
report_id: str | None = Field(default=None, min_length=1)
|
||||
namespace: str | None = Field(default="awoooi-prod", min_length=1)
|
||||
remediation_kind: Literal[
|
||||
"live_env_rollback",
|
||||
"git_adopted",
|
||||
"git_rollback",
|
||||
"zero_diff_pr_cleanup",
|
||||
"manual_noop",
|
||||
] = "live_env_rollback"
|
||||
remediation_status: Literal[
|
||||
"executed_unverified",
|
||||
"verified_no_drift",
|
||||
"verification_failed",
|
||||
] | None = None
|
||||
verification_report_id: str | None = Field(default=None, min_length=1)
|
||||
note: str | None = Field(default=None, max_length=1000)
|
||||
commands_summary: list[str] = Field(default_factory=list, max_length=12)
|
||||
|
||||
|
||||
@router.post("/scan", response_model=DriftScanResponse, summary="觸發漂移掃描")
|
||||
async def trigger_drift_scan(
|
||||
request: DriftScanRequest,
|
||||
@@ -160,6 +182,31 @@ async def record_drift_fingerprint_handoff(
|
||||
raise HTTPException(status_code=404, detail="drift_report_not_found") from exc
|
||||
|
||||
|
||||
@router.post("/fingerprints/remediation", summary="記錄 Config Drift fingerprint 修復")
|
||||
async def record_drift_fingerprint_remediation(
|
||||
request: DriftFingerprintRemediationRequest,
|
||||
) -> dict:
|
||||
"""
|
||||
記錄 stable fingerprint 已完成的修復 / 驗證證據。
|
||||
|
||||
安全邊界:只寫 alert_operation_log / timeline_events,不修改 drift 狀態、
|
||||
incident 狀態、自動修復結果,不建立外部 ticket,也不執行 kubectl。
|
||||
"""
|
||||
svc = get_drift_fingerprint_state_service()
|
||||
try:
|
||||
return await svc.record_remediation(
|
||||
report_id=request.report_id,
|
||||
namespace=request.namespace,
|
||||
remediation_kind=request.remediation_kind,
|
||||
remediation_status=request.remediation_status,
|
||||
verification_report_id=request.verification_report_id,
|
||||
note=request.note,
|
||||
commands_summary=request.commands_summary,
|
||||
)
|
||||
except DriftFingerprintStateNotFoundError as exc:
|
||||
raise HTTPException(status_code=404, detail="drift_report_not_found") from exc
|
||||
|
||||
|
||||
@router.post("/reports/{report_id}/rollback", summary="覆蓋回 Git 狀態")
|
||||
async def rollback_drift(report_id: str, _csrf_token: CSRFToken) -> dict: # Phase 20: CSRF Protection (驗證用,不需要使用值)
|
||||
"""
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
The drift scanner creates a new report_id on every scan, so the operator-facing
|
||||
state must be keyed by the stable drift fingerprint instead of the volatile
|
||||
report id. This service is intentionally conservative: it can record a human
|
||||
handoff breadcrumb, but it does not adopt, merge, roll back, or mutate incident
|
||||
state.
|
||||
report id. This service is intentionally conservative: it can record human
|
||||
handoff and remediation breadcrumbs, but it does not adopt, merge, roll back,
|
||||
or mutate incident state.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -27,6 +27,7 @@ logger = structlog.get_logger(__name__)
|
||||
|
||||
SCHEMA_VERSION = "drift_fingerprint_state_v1"
|
||||
HANDOFF_SCHEMA_VERSION = "drift_fingerprint_handoff_history_v1"
|
||||
REMEDIATION_SCHEMA_VERSION = "drift_fingerprint_remediation_history_v1"
|
||||
SERVICE_ACTOR = "drift_fingerprint_state_service"
|
||||
DEFAULT_NAMESPACE = "awoooi-prod"
|
||||
|
||||
@@ -36,6 +37,20 @@ DriftFingerprintHandoffKind = Literal[
|
||||
"zero_diff_pr_cleanup",
|
||||
]
|
||||
|
||||
DriftFingerprintRemediationKind = Literal[
|
||||
"live_env_rollback",
|
||||
"git_adopted",
|
||||
"git_rollback",
|
||||
"zero_diff_pr_cleanup",
|
||||
"manual_noop",
|
||||
]
|
||||
|
||||
DriftFingerprintRemediationStatus = Literal[
|
||||
"executed_unverified",
|
||||
"verified_no_drift",
|
||||
"verification_failed",
|
||||
]
|
||||
|
||||
|
||||
class DriftFingerprintStateNotFoundError(Exception):
|
||||
"""Raised when no drift report can be found for the requested selector."""
|
||||
@@ -87,8 +102,19 @@ def _derive_fsm_state(
|
||||
repeat_state: dict[str, Any],
|
||||
open_pr: dict[str, Any] | None,
|
||||
latest_handoff: dict[str, Any] | None,
|
||||
latest_remediation: dict[str, Any] | None,
|
||||
) -> str:
|
||||
status = str(_enum_value(report.status))
|
||||
remediation_status = (latest_remediation or {}).get("remediation_status")
|
||||
if remediation_status == "verified_no_drift":
|
||||
if report.high_count == 0 and report.medium_count == 0 and report.info_count == 0:
|
||||
return "no_drift_verified"
|
||||
return "remediated_verified"
|
||||
if remediation_status == "executed_unverified":
|
||||
return "remediation_executed_unverified"
|
||||
if remediation_status == "verification_failed":
|
||||
return "remediation_verification_failed"
|
||||
|
||||
if status == "adopted":
|
||||
return "adopted_unverified"
|
||||
if status == "rolled_back":
|
||||
@@ -113,6 +139,12 @@ def _derive_fsm_state(
|
||||
|
||||
|
||||
def _next_step_for_state(state: str, open_pr: dict[str, Any] | None) -> str:
|
||||
if state in {"no_drift_verified", "remediated_verified"}:
|
||||
return "monitor_for_recurrence"
|
||||
if state == "remediation_executed_unverified":
|
||||
return "run_verification_scan_then_record_result"
|
||||
if state == "remediation_verification_failed":
|
||||
return "open_manual_investigation_with_failed_verification"
|
||||
if state == "pr_open_zero_diff":
|
||||
return "close_zero_diff_pr_and_prepare_real_yaml_patch"
|
||||
if state == "pr_open_waiting_review":
|
||||
@@ -138,6 +170,7 @@ def build_drift_fingerprint_state(
|
||||
*,
|
||||
open_pr: dict[str, Any] | None = None,
|
||||
latest_handoff: dict[str, Any] | None = None,
|
||||
latest_remediation: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build the operator-facing Config Drift fingerprint state payload."""
|
||||
|
||||
@@ -145,7 +178,13 @@ def build_drift_fingerprint_state(
|
||||
report.namespace,
|
||||
report.items,
|
||||
)
|
||||
fsm_state = _derive_fsm_state(report, repeat_state, open_pr, latest_handoff)
|
||||
fsm_state = _derive_fsm_state(
|
||||
report,
|
||||
repeat_state,
|
||||
open_pr,
|
||||
latest_handoff,
|
||||
latest_remediation,
|
||||
)
|
||||
next_step = _next_step_for_state(fsm_state, open_pr)
|
||||
|
||||
return {
|
||||
@@ -169,6 +208,7 @@ def build_drift_fingerprint_state(
|
||||
"next_step": next_step,
|
||||
"open_pr": open_pr,
|
||||
"latest_handoff": latest_handoff,
|
||||
"latest_remediation": latest_remediation,
|
||||
"strict_fingerprint": repeat_state.get("strict_fingerprint"),
|
||||
"p0_escalation": {
|
||||
"suppresses_repeated_p0": True,
|
||||
@@ -188,6 +228,7 @@ def build_drift_fingerprint_state(
|
||||
"writes_auto_repair_result": False,
|
||||
"writes_drift_status": False,
|
||||
"writes_ticket": False,
|
||||
"writes_remediation_record": False,
|
||||
"creates_external_ticket": False,
|
||||
}
|
||||
|
||||
@@ -230,6 +271,71 @@ def _handoff_description(context: dict[str, Any]) -> str:
|
||||
)[:500]
|
||||
|
||||
|
||||
def _is_no_drift_report(report: DriftReport | None) -> bool:
|
||||
if report is None:
|
||||
return False
|
||||
return report.high_count == 0 and report.medium_count == 0 and report.info_count == 0
|
||||
|
||||
|
||||
def _remediation_context(
|
||||
state: dict[str, Any],
|
||||
*,
|
||||
remediation_kind: DriftFingerprintRemediationKind,
|
||||
remediation_status: DriftFingerprintRemediationStatus,
|
||||
verification_report: DriftReport | None,
|
||||
note: str | None,
|
||||
commands_summary: list[str] | None,
|
||||
) -> dict[str, Any]:
|
||||
verification_summary = None
|
||||
if verification_report is not None:
|
||||
verification_summary = {
|
||||
"report_id": verification_report.report_id,
|
||||
"status": str(_enum_value(verification_report.status)),
|
||||
"summary": _report_summary(verification_report),
|
||||
"high_count": verification_report.high_count,
|
||||
"medium_count": verification_report.medium_count,
|
||||
"info_count": verification_report.info_count,
|
||||
"scanned_at": _iso(verification_report.scanned_at),
|
||||
"is_no_drift": _is_no_drift_report(verification_report),
|
||||
}
|
||||
|
||||
return {
|
||||
"schema_version": REMEDIATION_SCHEMA_VERSION,
|
||||
"fingerprint": state.get("fingerprint"),
|
||||
"namespace": state.get("namespace"),
|
||||
"remediated_report_id": state.get("latest_report_id"),
|
||||
"verification_report_id": getattr(verification_report, "report_id", None),
|
||||
"verification_summary": verification_summary,
|
||||
"remediation_kind": remediation_kind,
|
||||
"remediation_status": remediation_status,
|
||||
"note": note,
|
||||
"commands_summary": [
|
||||
str(command)[:180] for command in (commands_summary or [])[:12]
|
||||
],
|
||||
"fsm_state_before": state.get("fsm_state"),
|
||||
"next_step_before": state.get("next_step"),
|
||||
"writes_incident_state": False,
|
||||
"writes_auto_repair_result": False,
|
||||
"writes_drift_status": False,
|
||||
"writes_ticket": False,
|
||||
"writes_remediation_record": True,
|
||||
"creates_external_ticket": False,
|
||||
"recorded_at": now_taipei().isoformat(),
|
||||
}
|
||||
|
||||
|
||||
def _remediation_description(context: dict[str, Any]) -> str:
|
||||
verification = context.get("verification_summary") or {}
|
||||
return (
|
||||
f"fingerprint={context.get('fingerprint')} "
|
||||
f"kind={context.get('remediation_kind')} "
|
||||
f"status={context.get('remediation_status')} "
|
||||
f"remediated_report={context.get('remediated_report_id')} "
|
||||
f"verification_report={context.get('verification_report_id') or '--'} "
|
||||
f"verification={verification.get('summary') or '--'}"
|
||||
)[:500]
|
||||
|
||||
|
||||
class DriftFingerprintStateService:
|
||||
"""Read and record the state of a stable Config Drift fingerprint."""
|
||||
|
||||
@@ -249,11 +355,22 @@ class DriftFingerprintStateService:
|
||||
alternate_fingerprints=_repeat_state_fingerprint_aliases(repeat_state),
|
||||
report_ids=_repeat_state_report_ids(repeat_state),
|
||||
)
|
||||
latest_remediation = await self._fetch_latest_remediation(
|
||||
fingerprint,
|
||||
alternate_fingerprints=_repeat_state_fingerprint_aliases(repeat_state),
|
||||
report_ids={
|
||||
report.report_id,
|
||||
*_repeat_state_report_ids(repeat_state),
|
||||
},
|
||||
namespace=report.namespace,
|
||||
allow_namespace_fallback=_is_no_drift_report(report),
|
||||
)
|
||||
return build_drift_fingerprint_state(
|
||||
report,
|
||||
repeat_state,
|
||||
open_pr=open_pr,
|
||||
latest_handoff=latest_handoff,
|
||||
latest_remediation=latest_remediation,
|
||||
)
|
||||
|
||||
async def record_handoff(
|
||||
@@ -357,6 +474,132 @@ class DriftFingerprintStateService:
|
||||
"history": history,
|
||||
}
|
||||
|
||||
async def record_remediation(
|
||||
self,
|
||||
*,
|
||||
report_id: str | None = None,
|
||||
namespace: str | None = None,
|
||||
remediation_kind: DriftFingerprintRemediationKind = "live_env_rollback",
|
||||
remediation_status: DriftFingerprintRemediationStatus | None = None,
|
||||
verification_report_id: str | None = None,
|
||||
note: str | None = None,
|
||||
commands_summary: list[str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
state = await self.get_state(report_id=report_id, namespace=namespace)
|
||||
verification_report = await self._load_verification_report(
|
||||
report_id=verification_report_id,
|
||||
namespace=state.get("namespace") or namespace,
|
||||
)
|
||||
resolved_status: DriftFingerprintRemediationStatus = (
|
||||
remediation_status
|
||||
or (
|
||||
"verified_no_drift"
|
||||
if _is_no_drift_report(verification_report)
|
||||
else "executed_unverified"
|
||||
)
|
||||
)
|
||||
context = _remediation_context(
|
||||
state,
|
||||
remediation_kind=remediation_kind,
|
||||
remediation_status=resolved_status,
|
||||
verification_report=verification_report,
|
||||
note=note,
|
||||
commands_summary=commands_summary,
|
||||
)
|
||||
|
||||
history = {
|
||||
"recorded": False,
|
||||
"alert_operation_id": None,
|
||||
"timeline_event_id": None,
|
||||
"reason": None,
|
||||
}
|
||||
incident_id = str(state.get("latest_report_id") or "")
|
||||
success = resolved_status != "verification_failed"
|
||||
|
||||
try:
|
||||
from src.repositories.alert_operation_log_repository import (
|
||||
get_alert_operation_log_repository,
|
||||
)
|
||||
|
||||
record = await get_alert_operation_log_repository().append(
|
||||
"CHANGE_APPLIED",
|
||||
incident_id=incident_id or None,
|
||||
actor=SERVICE_ACTOR,
|
||||
action_detail=(
|
||||
f"drift_fingerprint_remediation:{remediation_kind}"
|
||||
)[:200],
|
||||
success=success,
|
||||
context=context,
|
||||
)
|
||||
if record is not None:
|
||||
history["alert_operation_id"] = getattr(record, "id", None)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"drift_fingerprint_remediation_aol_failed",
|
||||
report_id=incident_id,
|
||||
error=str(exc),
|
||||
)
|
||||
|
||||
try:
|
||||
from src.services.approval_db import get_timeline_service
|
||||
|
||||
event = await get_timeline_service().add_event(
|
||||
event_type="exec",
|
||||
status="success" if success else "error",
|
||||
title="AwoooP drift remediation verified",
|
||||
description=_remediation_description(context),
|
||||
actor=SERVICE_ACTOR,
|
||||
actor_role=remediation_kind,
|
||||
incident_id=incident_id or None,
|
||||
)
|
||||
if event:
|
||||
history["timeline_event_id"] = event.get("id")
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"drift_fingerprint_remediation_timeline_failed",
|
||||
report_id=incident_id,
|
||||
error=str(exc),
|
||||
)
|
||||
|
||||
history["recorded"] = bool(
|
||||
history.get("alert_operation_id") or history.get("timeline_event_id")
|
||||
)
|
||||
if not history["recorded"]:
|
||||
history["reason"] = "history_sink_unavailable"
|
||||
|
||||
latest_remediation = {
|
||||
"remediation_kind": remediation_kind,
|
||||
"remediation_status": (
|
||||
resolved_status if history["recorded"] else "record_failed"
|
||||
),
|
||||
"verification_report_id": context.get("verification_report_id"),
|
||||
"verification_summary": context.get("verification_summary"),
|
||||
"note": note,
|
||||
"commands_summary": context.get("commands_summary"),
|
||||
"created_at": context.get("recorded_at"),
|
||||
"source": "current_request",
|
||||
}
|
||||
updated_state = build_drift_fingerprint_state(
|
||||
await self._load_report(
|
||||
report_id=str(state.get("latest_report_id") or ""),
|
||||
namespace=state.get("namespace"),
|
||||
),
|
||||
{
|
||||
**(state.get("repeat_state") or {}),
|
||||
"fingerprint": state.get("fingerprint"),
|
||||
},
|
||||
open_pr=state.get("open_pr"),
|
||||
latest_handoff=state.get("latest_handoff"),
|
||||
latest_remediation=latest_remediation,
|
||||
)
|
||||
|
||||
return {
|
||||
**updated_state,
|
||||
"remediation_kind": remediation_kind,
|
||||
"remediation_status": latest_remediation["remediation_status"],
|
||||
"history": history,
|
||||
}
|
||||
|
||||
async def _load_report(
|
||||
self,
|
||||
*,
|
||||
@@ -376,6 +619,22 @@ class DriftFingerprintStateService:
|
||||
return report
|
||||
raise DriftFingerprintStateNotFoundError(desired_namespace)
|
||||
|
||||
async def _load_verification_report(
|
||||
self,
|
||||
*,
|
||||
report_id: str | None,
|
||||
namespace: str | None,
|
||||
) -> DriftReport | None:
|
||||
repo = get_drift_repository()
|
||||
if report_id:
|
||||
return await repo.get(report_id)
|
||||
|
||||
desired_namespace = namespace or DEFAULT_NAMESPACE
|
||||
for report in await repo.list_recent(limit=50):
|
||||
if report.namespace == desired_namespace and _is_no_drift_report(report):
|
||||
return report
|
||||
return None
|
||||
|
||||
async def _fetch_latest_handoff(
|
||||
self,
|
||||
fingerprint: str,
|
||||
@@ -439,6 +698,86 @@ class DriftFingerprintStateService:
|
||||
"note": context.get("note"),
|
||||
}
|
||||
|
||||
async def _fetch_latest_remediation(
|
||||
self,
|
||||
fingerprint: str,
|
||||
*,
|
||||
alternate_fingerprints: set[str] | None = None,
|
||||
report_ids: set[str] | None = None,
|
||||
namespace: str | None = None,
|
||||
allow_namespace_fallback: bool = False,
|
||||
) -> dict[str, Any] | None:
|
||||
fingerprints = {fingerprint, *(alternate_fingerprints or set())}
|
||||
fingerprints = {value for value in fingerprints if value}
|
||||
report_ids = {value for value in (report_ids or set()) if value}
|
||||
try:
|
||||
async with get_db_context() as db:
|
||||
result = await db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT id, action_detail, success, context, created_at
|
||||
FROM alert_operation_log
|
||||
WHERE actor = :actor
|
||||
AND action_detail LIKE 'drift_fingerprint_remediation:%'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 100
|
||||
"""
|
||||
),
|
||||
{"actor": SERVICE_ACTOR},
|
||||
)
|
||||
rows = result.mappings().all()
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"drift_fingerprint_remediation_lookup_failed",
|
||||
fingerprint=fingerprint,
|
||||
error=str(exc),
|
||||
)
|
||||
return {
|
||||
"lookup_error": str(exc)[:160],
|
||||
"remediation_status": "lookup_failed",
|
||||
}
|
||||
|
||||
namespace_fallback = None
|
||||
row = None
|
||||
for candidate in rows:
|
||||
context = candidate.get("context") or {}
|
||||
if not isinstance(context, dict):
|
||||
continue
|
||||
if context.get("fingerprint") in fingerprints:
|
||||
row = candidate
|
||||
break
|
||||
if context.get("remediated_report_id") in report_ids:
|
||||
row = candidate
|
||||
break
|
||||
if context.get("verification_report_id") in report_ids:
|
||||
row = candidate
|
||||
break
|
||||
if (
|
||||
allow_namespace_fallback
|
||||
and namespace
|
||||
and context.get("namespace") == namespace
|
||||
and namespace_fallback is None
|
||||
):
|
||||
namespace_fallback = candidate
|
||||
|
||||
row = row or namespace_fallback
|
||||
if row is None:
|
||||
return None
|
||||
context = row.get("context") or {}
|
||||
return {
|
||||
"alert_operation_id": row.get("id"),
|
||||
"action_detail": row.get("action_detail"),
|
||||
"success": row.get("success"),
|
||||
"created_at": _iso(row.get("created_at")),
|
||||
"remediation_kind": context.get("remediation_kind"),
|
||||
"remediation_status": context.get("remediation_status") or "recorded",
|
||||
"remediated_report_id": context.get("remediated_report_id"),
|
||||
"verification_report_id": context.get("verification_report_id"),
|
||||
"verification_summary": context.get("verification_summary"),
|
||||
"note": context.get("note"),
|
||||
"commands_summary": context.get("commands_summary"),
|
||||
}
|
||||
|
||||
async def _lookup_open_pr(self, report: DriftReport) -> dict[str, Any] | None:
|
||||
settings = get_settings()
|
||||
api_url = settings.GITEA_API_URL.rstrip("/")
|
||||
|
||||
@@ -27,6 +27,18 @@ def _report(report_id: str = "drift-1", status: DriftStatus = DriftStatus.PENDIN
|
||||
)
|
||||
|
||||
|
||||
def _no_drift_report(report_id: str = "no-drift-1") -> DriftReport:
|
||||
return DriftReport(
|
||||
report_id=report_id,
|
||||
namespace="awoooi-prod",
|
||||
high_count=0,
|
||||
medium_count=0,
|
||||
info_count=0,
|
||||
status=DriftStatus.IGNORED,
|
||||
items=[],
|
||||
)
|
||||
|
||||
|
||||
def test_build_state_marks_repeated_pending_human() -> None:
|
||||
current = _report("drift-3")
|
||||
repeat = build_drift_repeat_state(
|
||||
@@ -81,3 +93,38 @@ def test_build_state_marks_handoff_recorded() -> None:
|
||||
|
||||
assert state["fsm_state"] == "handoff_recorded"
|
||||
assert state["next_step"] == "operator_review_handoff_and_execute_manual_plan"
|
||||
|
||||
|
||||
def test_build_state_marks_verified_no_drift_remediation() -> None:
|
||||
report = _no_drift_report("no-drift-2")
|
||||
repeat = build_drift_repeat_state(report, [])
|
||||
state = build_drift_fingerprint_state(
|
||||
report,
|
||||
repeat,
|
||||
latest_remediation={
|
||||
"remediation_kind": "live_env_rollback",
|
||||
"remediation_status": "verified_no_drift",
|
||||
"verification_report_id": "no-drift-2",
|
||||
},
|
||||
)
|
||||
|
||||
assert state["fsm_state"] == "no_drift_verified"
|
||||
assert state["next_step"] == "monitor_for_recurrence"
|
||||
assert state["latest_remediation"]["remediation_status"] == "verified_no_drift"
|
||||
assert state["writes_remediation_record"] is False
|
||||
|
||||
|
||||
def test_build_state_marks_remediation_executed_unverified() -> None:
|
||||
report = _report("drift-6")
|
||||
repeat = build_drift_repeat_state(report, [])
|
||||
state = build_drift_fingerprint_state(
|
||||
report,
|
||||
repeat,
|
||||
latest_remediation={
|
||||
"remediation_kind": "live_env_rollback",
|
||||
"remediation_status": "executed_unverified",
|
||||
},
|
||||
)
|
||||
|
||||
assert state["fsm_state"] == "remediation_executed_unverified"
|
||||
assert state["next_step"] == "run_verification_scan_then_record_result"
|
||||
|
||||
@@ -1069,7 +1069,11 @@
|
||||
"next": "Next: {step}",
|
||||
"writes": "Writes: drift={drift}; incident={incident}; repair={repair}; ticket={ticket}",
|
||||
"pr": "PR: {pr}; zeroDiff={zeroDiff}",
|
||||
"p0Dedup": "P0 dedupe {hours}h"
|
||||
"p0Dedup": "P0 dedupe {hours}h",
|
||||
"remediation": "Remediation: {status}; verification report: {report}",
|
||||
"remediationKind": "Remediation kind: {kind}",
|
||||
"remediationVerification": "Verification: {summary}",
|
||||
"remediationNote": "Note: {note}"
|
||||
}
|
||||
},
|
||||
"neuralCommand": {
|
||||
@@ -1814,6 +1818,7 @@
|
||||
"driftFingerprintId": "Fingerprint: {fingerprint}; Report: {report}",
|
||||
"driftFingerprintPr": "PR: {pr}; zeroDiff={zeroDiff}",
|
||||
"driftFingerprintNext": "Next: {step}",
|
||||
"driftFingerprintRemediation": "Remediation: {kind} / {status}; verification report: {report}",
|
||||
"driftFingerprintEmpty": "No Config Drift fingerprint state yet",
|
||||
"remediationQueue": "Remediation work: {total}; AI-ready: {ready}; human: {human}",
|
||||
"telegramCallbacks": "Telegram callback lookup and history summary are being repaired",
|
||||
@@ -1866,6 +1871,10 @@
|
||||
"pr_open_waiting_review": "PR waiting review",
|
||||
"pr_merged_unverified": "PR merged, unverified",
|
||||
"handoff_recorded": "Handoff recorded",
|
||||
"no_drift_verified": "No drift, verified",
|
||||
"remediated_verified": "Remediated, verified",
|
||||
"remediation_executed_unverified": "Remediated, unverified",
|
||||
"remediation_verification_failed": "Remediation verification failed",
|
||||
"adopted_unverified": "Adopted, unverified",
|
||||
"rolled_back": "Rolled back",
|
||||
"acknowledged": "Acknowledged",
|
||||
@@ -1877,6 +1886,8 @@
|
||||
"review_pr_then_merge_or_reject": "Review PR, then merge or reject",
|
||||
"verify_git_baseline_then_mark_adopted": "Verify Git baseline, then mark adopted",
|
||||
"operator_review_handoff_and_execute_manual_plan": "Operator reviews handoff and executes manual plan",
|
||||
"run_verification_scan_then_record_result": "Run verification scan, then record the result",
|
||||
"open_manual_investigation_with_failed_verification": "Open manual investigation with the failed verification",
|
||||
"verify_k8s_matches_git_baseline": "Verify K8s matches Git baseline",
|
||||
"confirm_no_repeat_after_rollback": "Confirm no repeat after rollback",
|
||||
"monitor_for_recurrence": "Monitor for recurrence",
|
||||
@@ -1893,6 +1904,28 @@
|
||||
"handoff": {
|
||||
"latest": "Latest handoff: {status}"
|
||||
},
|
||||
"remediation": {
|
||||
"title": "Remediation / Verification",
|
||||
"latest": "Latest remediation: {kind} / {status}",
|
||||
"verification": "Verification report: {report}; {summary}",
|
||||
"note": "Note: {note}"
|
||||
},
|
||||
"remediationKinds": {
|
||||
"live_env_rollback": "Live env rollback",
|
||||
"git_adopted": "Git adopted",
|
||||
"git_rollback": "Git rollback",
|
||||
"zero_diff_pr_cleanup": "Zero-diff PR cleanup",
|
||||
"manual_noop": "Manual no-op",
|
||||
"unknown": "Unknown"
|
||||
},
|
||||
"remediationStatuses": {
|
||||
"executed_unverified": "Executed, unverified",
|
||||
"verified_no_drift": "Verified no drift",
|
||||
"verification_failed": "Verification failed",
|
||||
"record_failed": "Record failed",
|
||||
"lookup_failed": "Lookup failed",
|
||||
"unknown": "No record yet"
|
||||
},
|
||||
"actions": {
|
||||
"record": "Record handoff",
|
||||
"recording": "Recording",
|
||||
|
||||
@@ -1070,7 +1070,11 @@
|
||||
"next": "下一步:{step}",
|
||||
"writes": "寫入:drift={drift};incident={incident};repair={repair};ticket={ticket}",
|
||||
"pr": "PR:{pr};zeroDiff={zeroDiff}",
|
||||
"p0Dedup": "P0 去重 {hours}h"
|
||||
"p0Dedup": "P0 去重 {hours}h",
|
||||
"remediation": "修復:{status};驗證 Report:{report}",
|
||||
"remediationKind": "修復方式:{kind}",
|
||||
"remediationVerification": "驗證結果:{summary}",
|
||||
"remediationNote": "備註:{note}"
|
||||
}
|
||||
},
|
||||
"neuralCommand": {
|
||||
@@ -1815,6 +1819,7 @@
|
||||
"driftFingerprintId": "Fingerprint:{fingerprint};Report:{report}",
|
||||
"driftFingerprintPr": "PR:{pr};zeroDiff={zeroDiff}",
|
||||
"driftFingerprintNext": "下一步:{step}",
|
||||
"driftFingerprintRemediation": "修復:{kind} / {status};驗證 Report:{report}",
|
||||
"driftFingerprintEmpty": "尚無 Config Drift fingerprint 狀態",
|
||||
"remediationQueue": "補救工作:{total};AI 可接手:{ready};人工:{human}",
|
||||
"telegramCallbacks": "目前修補 Telegram callback 查詢鏈與歷史摘要",
|
||||
@@ -1867,6 +1872,10 @@
|
||||
"pr_open_waiting_review": "PR 等待 review",
|
||||
"pr_merged_unverified": "PR 已 merge 待驗證",
|
||||
"handoff_recorded": "交接已記錄",
|
||||
"no_drift_verified": "無漂移且已驗證",
|
||||
"remediated_verified": "已修復且已驗證",
|
||||
"remediation_executed_unverified": "已修復待驗證",
|
||||
"remediation_verification_failed": "修復驗證失敗",
|
||||
"adopted_unverified": "已採納待驗證",
|
||||
"rolled_back": "已回滾",
|
||||
"acknowledged": "已知悉",
|
||||
@@ -1878,6 +1887,8 @@
|
||||
"review_pr_then_merge_or_reject": "review PR 後 merge 或 reject",
|
||||
"verify_git_baseline_then_mark_adopted": "驗證 Git baseline 後標記採納",
|
||||
"operator_review_handoff_and_execute_manual_plan": "Operator review 交接並執行人工方案",
|
||||
"run_verification_scan_then_record_result": "執行驗證掃描並記錄結果",
|
||||
"open_manual_investigation_with_failed_verification": "建立人工調查並附上失敗驗證",
|
||||
"verify_k8s_matches_git_baseline": "驗證 K8s 與 Git baseline 一致",
|
||||
"confirm_no_repeat_after_rollback": "確認回滾後不再重複",
|
||||
"monitor_for_recurrence": "監控是否復發",
|
||||
@@ -1894,6 +1905,28 @@
|
||||
"handoff": {
|
||||
"latest": "最近交接:{status}"
|
||||
},
|
||||
"remediation": {
|
||||
"title": "修復 / 驗證",
|
||||
"latest": "最近修復:{kind} / {status}",
|
||||
"verification": "驗證 Report:{report};{summary}",
|
||||
"note": "備註:{note}"
|
||||
},
|
||||
"remediationKinds": {
|
||||
"live_env_rollback": "線上 env 回滾",
|
||||
"git_adopted": "Git 採納",
|
||||
"git_rollback": "Git 回滾",
|
||||
"zero_diff_pr_cleanup": "零 diff PR 清理",
|
||||
"manual_noop": "人工確認無需動作",
|
||||
"unknown": "未知"
|
||||
},
|
||||
"remediationStatuses": {
|
||||
"executed_unverified": "已執行待驗證",
|
||||
"verified_no_drift": "已驗證無漂移",
|
||||
"verification_failed": "驗證失敗",
|
||||
"record_failed": "入庫失敗",
|
||||
"lookup_failed": "查詢失敗",
|
||||
"unknown": "尚無記錄"
|
||||
},
|
||||
"actions": {
|
||||
"record": "記錄交接",
|
||||
"recording": "記錄中",
|
||||
|
||||
@@ -229,6 +229,24 @@ type DriftFingerprintState = {
|
||||
created_at?: string | null;
|
||||
lookup_error?: string | null;
|
||||
} | null;
|
||||
latest_remediation?: {
|
||||
remediation_kind?: string | null;
|
||||
remediation_status?: string | null;
|
||||
remediated_report_id?: string | null;
|
||||
verification_report_id?: string | null;
|
||||
verification_summary?: {
|
||||
report_id?: string | null;
|
||||
status?: string | null;
|
||||
summary?: string | null;
|
||||
high_count?: number | null;
|
||||
medium_count?: number | null;
|
||||
info_count?: number | null;
|
||||
is_no_drift?: boolean | null;
|
||||
} | null;
|
||||
note?: string | null;
|
||||
created_at?: string | null;
|
||||
lookup_error?: string | null;
|
||||
} | null;
|
||||
p0_escalation?: {
|
||||
suppresses_repeated_p0?: boolean | null;
|
||||
dedup_window_hours?: number | null;
|
||||
@@ -422,6 +440,10 @@ function driftFsmStateKey(state?: string | null) {
|
||||
state === "pr_open_waiting_review" ||
|
||||
state === "pr_merged_unverified" ||
|
||||
state === "handoff_recorded" ||
|
||||
state === "no_drift_verified" ||
|
||||
state === "remediated_verified" ||
|
||||
state === "remediation_executed_unverified" ||
|
||||
state === "remediation_verification_failed" ||
|
||||
state === "adopted_unverified" ||
|
||||
state === "rolled_back" ||
|
||||
state === "acknowledged" ||
|
||||
@@ -438,6 +460,8 @@ function driftNextStepKey(step?: string | null) {
|
||||
step === "review_pr_then_merge_or_reject" ||
|
||||
step === "verify_git_baseline_then_mark_adopted" ||
|
||||
step === "operator_review_handoff_and_execute_manual_plan" ||
|
||||
step === "run_verification_scan_then_record_result" ||
|
||||
step === "open_manual_investigation_with_failed_verification" ||
|
||||
step === "verify_k8s_matches_git_baseline" ||
|
||||
step === "confirm_no_repeat_after_rollback" ||
|
||||
step === "monitor_for_recurrence" ||
|
||||
@@ -449,9 +473,38 @@ function driftNextStepKey(step?: string | null) {
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function driftRemediationKindKey(kind?: string | null) {
|
||||
if (
|
||||
kind === "live_env_rollback" ||
|
||||
kind === "git_adopted" ||
|
||||
kind === "git_rollback" ||
|
||||
kind === "zero_diff_pr_cleanup" ||
|
||||
kind === "manual_noop"
|
||||
) {
|
||||
return kind;
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function driftRemediationStatusKey(status?: string | null) {
|
||||
if (
|
||||
status === "executed_unverified" ||
|
||||
status === "verified_no_drift" ||
|
||||
status === "verification_failed" ||
|
||||
status === "record_failed" ||
|
||||
status === "lookup_failed"
|
||||
) {
|
||||
return status;
|
||||
}
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function driftStatusForWorkItem(state: DriftFingerprintState | null): WorkStatus {
|
||||
const fsm = state?.fsm_state;
|
||||
if (!state) return "blocked";
|
||||
if (fsm === "no_drift_verified" || fsm === "remediated_verified") return "live";
|
||||
if (fsm === "remediation_executed_unverified") return "in_progress";
|
||||
if (fsm === "remediation_verification_failed") return "blocked";
|
||||
if (fsm === "adopted_unverified" || fsm === "pr_merged_unverified") return "in_progress";
|
||||
if (fsm === "rolled_back" || fsm === "acknowledged" || fsm === "ignored") return "watching";
|
||||
if (fsm === "pr_open_waiting_review" || fsm === "handoff_recorded") return "in_progress";
|
||||
@@ -482,6 +535,12 @@ function buildWorkItems(
|
||||
const driftState = telemetry.driftFingerprintState;
|
||||
const driftFsmKey = driftFsmStateKey(driftState?.fsm_state);
|
||||
const driftNextKey = driftNextStepKey(driftState?.next_step);
|
||||
const driftRemediationKind = driftRemediationKindKey(
|
||||
driftState?.latest_remediation?.remediation_kind
|
||||
);
|
||||
const driftRemediationStatus = driftRemediationStatusKey(
|
||||
driftState?.latest_remediation?.remediation_status
|
||||
);
|
||||
const governanceEventsUnavailable = telemetry.governanceEvents === null;
|
||||
const governanceQueueMissing = telemetry.governanceQueue?.table_pending === true;
|
||||
const governanceDispatchBlocked =
|
||||
@@ -575,6 +634,13 @@ function buildWorkItems(
|
||||
t("evidence.driftFingerprintNext", {
|
||||
step: t(`driftFingerprint.nextSteps.${driftNextKey}` as never),
|
||||
}),
|
||||
t("evidence.driftFingerprintRemediation", {
|
||||
kind: t(`driftFingerprint.remediationKinds.${driftRemediationKind}` as never),
|
||||
status: t(
|
||||
`driftFingerprint.remediationStatuses.${driftRemediationStatus}` as never
|
||||
),
|
||||
report: driftState.latest_remediation?.verification_report_id ?? "--",
|
||||
}),
|
||||
]
|
||||
: [t("evidence.driftFingerprintEmpty")],
|
||||
href: "/drift",
|
||||
@@ -1104,6 +1170,12 @@ function DriftFingerprintPanel({
|
||||
});
|
||||
const fsmKey = driftFsmStateKey(state?.fsm_state);
|
||||
const nextKey = driftNextStepKey(state?.next_step);
|
||||
const remediationKindKey = driftRemediationKindKey(
|
||||
state?.latest_remediation?.remediation_kind
|
||||
);
|
||||
const remediationStatusKey = driftRemediationStatusKey(
|
||||
state?.latest_remediation?.remediation_status
|
||||
);
|
||||
const handoffKind = state?.open_pr?.is_zero_diff
|
||||
? "zero_diff_pr_cleanup"
|
||||
: "open_pr_review";
|
||||
@@ -1222,6 +1294,34 @@ function DriftFingerprintPanel({
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-4 border border-[#d8d3c7] bg-[#faf9f3] px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldCheck className="h-4 w-4 text-[#17602a]" aria-hidden="true" />
|
||||
<h4 className="text-sm font-semibold text-[#141413]">
|
||||
{t("remediation.title")}
|
||||
</h4>
|
||||
</div>
|
||||
<div className="mt-2 grid gap-1 text-xs leading-5 text-[#5f5b52]">
|
||||
<p>
|
||||
{t("remediation.latest", {
|
||||
kind: t(`remediationKinds.${remediationKindKey}` as never),
|
||||
status: t(`remediationStatuses.${remediationStatusKey}` as never),
|
||||
})}
|
||||
</p>
|
||||
<p>
|
||||
{t("remediation.verification", {
|
||||
report: state.latest_remediation?.verification_report_id ?? "--",
|
||||
summary:
|
||||
state.latest_remediation?.verification_summary?.summary ?? "--",
|
||||
})}
|
||||
</p>
|
||||
<p>
|
||||
{t("remediation.note", {
|
||||
note: state.latest_remediation?.note ?? "--",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -78,6 +78,17 @@ interface DriftFingerprintState {
|
||||
latest_handoff?: {
|
||||
handoff_status?: string | null
|
||||
} | null
|
||||
latest_remediation?: {
|
||||
remediation_kind?: string | null
|
||||
remediation_status?: string | null
|
||||
verification_report_id?: string | null
|
||||
verification_summary?: {
|
||||
summary?: string | null
|
||||
is_no_drift?: boolean | null
|
||||
} | null
|
||||
note?: string | null
|
||||
created_at?: string | null
|
||||
} | null
|
||||
p0_escalation?: {
|
||||
suppresses_repeated_p0?: boolean | null
|
||||
dedup_window_hours?: number | null
|
||||
@@ -231,7 +242,32 @@ function DriftFingerprintStateCard({
|
||||
hours: state.p0_escalation?.dedup_window_hours ?? 24,
|
||||
})}
|
||||
</span>
|
||||
<span className="border border-status-healthy/20 bg-status-healthy/10 px-2 py-0.5 text-status-healthy">
|
||||
{t('fingerprintState.remediation', {
|
||||
status: state.latest_remediation?.remediation_status ?? '--',
|
||||
report: state.latest_remediation?.verification_report_id ?? '--',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{state.latest_remediation ? (
|
||||
<div className="mt-3 border border-neutral-200 bg-white px-3 py-2 text-[11px] leading-5 text-neutral-500">
|
||||
<p>
|
||||
{t('fingerprintState.remediationKind', {
|
||||
kind: state.latest_remediation.remediation_kind ?? '--',
|
||||
})}
|
||||
</p>
|
||||
<p>
|
||||
{t('fingerprintState.remediationVerification', {
|
||||
summary: state.latest_remediation.verification_summary?.summary ?? '--',
|
||||
})}
|
||||
</p>
|
||||
<p>
|
||||
{t('fingerprintState.remediationNote', {
|
||||
note: state.latest_remediation.note ?? '--',
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user