nthlayer-workers 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- nthlayer_workers/__init__.py +5 -0
- nthlayer_workers/cli.py +234 -0
- nthlayer_workers/correlate/__init__.py +1 -0
- nthlayer_workers/correlate/cli.py +847 -0
- nthlayer_workers/correlate/config.py +111 -0
- nthlayer_workers/correlate/correlation/__init__.py +1 -0
- nthlayer_workers/correlate/correlation/changes.py +87 -0
- nthlayer_workers/correlate/correlation/dedup.py +62 -0
- nthlayer_workers/correlate/correlation/engine.py +244 -0
- nthlayer_workers/correlate/correlation/temporal.py +79 -0
- nthlayer_workers/correlate/correlation/topology.py +104 -0
- nthlayer_workers/correlate/ingestion/__init__.py +1 -0
- nthlayer_workers/correlate/ingestion/protocol.py +10 -0
- nthlayer_workers/correlate/ingestion/severity.py +18 -0
- nthlayer_workers/correlate/ingestion/webhook.py +197 -0
- nthlayer_workers/correlate/notifications.py +85 -0
- nthlayer_workers/correlate/prometheus.py +234 -0
- nthlayer_workers/correlate/reasoning.py +375 -0
- nthlayer_workers/correlate/session.py +189 -0
- nthlayer_workers/correlate/snapshot/__init__.py +1 -0
- nthlayer_workers/correlate/snapshot/generator.py +170 -0
- nthlayer_workers/correlate/snapshot/model.py +177 -0
- nthlayer_workers/correlate/snapshot/token.py +14 -0
- nthlayer_workers/correlate/state.py +88 -0
- nthlayer_workers/correlate/store/__init__.py +5 -0
- nthlayer_workers/correlate/store/protocol.py +48 -0
- nthlayer_workers/correlate/store/sqlite.py +443 -0
- nthlayer_workers/correlate/summary.py +180 -0
- nthlayer_workers/correlate/traces/__init__.py +1 -0
- nthlayer_workers/correlate/traces/protocol.py +120 -0
- nthlayer_workers/correlate/traces/tempo.py +667 -0
- nthlayer_workers/correlate/traces/topology.py +39 -0
- nthlayer_workers/correlate/types.py +77 -0
- nthlayer_workers/correlate/worker.py +630 -0
- nthlayer_workers/learn/__init__.py +5 -0
- nthlayer_workers/learn/__main__.py +5 -0
- nthlayer_workers/learn/cli.py +164 -0
- nthlayer_workers/learn/retrospective.py +381 -0
- nthlayer_workers/learn/trends.py +102 -0
- nthlayer_workers/learn/worker.py +366 -0
- nthlayer_workers/measure/__init__.py +3 -0
- nthlayer_workers/measure/__main__.py +5 -0
- nthlayer_workers/measure/_parsing.py +15 -0
- nthlayer_workers/measure/adapters/__init__.py +0 -0
- nthlayer_workers/measure/adapters/_util.py +24 -0
- nthlayer_workers/measure/adapters/devin.py +119 -0
- nthlayer_workers/measure/adapters/gastown.py +88 -0
- nthlayer_workers/measure/adapters/prometheus.py +277 -0
- nthlayer_workers/measure/adapters/protocol.py +20 -0
- nthlayer_workers/measure/adapters/webhook.py +161 -0
- nthlayer_workers/measure/api/__init__.py +0 -0
- nthlayer_workers/measure/api/normalise.py +50 -0
- nthlayer_workers/measure/api/queue.py +243 -0
- nthlayer_workers/measure/api/response.py +51 -0
- nthlayer_workers/measure/api/server.py +504 -0
- nthlayer_workers/measure/calibration/__init__.py +0 -0
- nthlayer_workers/measure/calibration/loop.py +62 -0
- nthlayer_workers/measure/calibration/slos.py +212 -0
- nthlayer_workers/measure/calibration/verdict_calibration.py +31 -0
- nthlayer_workers/measure/cli.py +753 -0
- nthlayer_workers/measure/config.py +191 -0
- nthlayer_workers/measure/detection/__init__.py +6 -0
- nthlayer_workers/measure/detection/detector.py +82 -0
- nthlayer_workers/measure/detection/protocol.py +29 -0
- nthlayer_workers/measure/governance/__init__.py +0 -0
- nthlayer_workers/measure/governance/engine.py +163 -0
- nthlayer_workers/measure/manifest.py +77 -0
- nthlayer_workers/measure/notifications.py +53 -0
- nthlayer_workers/measure/pipeline/__init__.py +0 -0
- nthlayer_workers/measure/pipeline/evaluator.py +155 -0
- nthlayer_workers/measure/pipeline/router.py +160 -0
- nthlayer_workers/measure/store/__init__.py +0 -0
- nthlayer_workers/measure/store/protocol.py +38 -0
- nthlayer_workers/measure/store/sqlite.py +276 -0
- nthlayer_workers/measure/telemetry.py +116 -0
- nthlayer_workers/measure/tiering/__init__.py +0 -0
- nthlayer_workers/measure/tiering/classifier.py +58 -0
- nthlayer_workers/measure/tiering/promotion.py +118 -0
- nthlayer_workers/measure/trends/__init__.py +0 -0
- nthlayer_workers/measure/trends/tracker.py +72 -0
- nthlayer_workers/measure/types.py +75 -0
- nthlayer_workers/measure/worker.py +439 -0
- nthlayer_workers/observe/__init__.py +25 -0
- nthlayer_workers/observe/__main__.py +5 -0
- nthlayer_workers/observe/api/__init__.py +1 -0
- nthlayer_workers/observe/assessment.py +95 -0
- nthlayer_workers/observe/cli.py +737 -0
- nthlayer_workers/observe/config.py +11 -0
- nthlayer_workers/observe/db/__init__.py +1 -0
- nthlayer_workers/observe/decision_records.py +220 -0
- nthlayer_workers/observe/dependencies/__init__.py +18 -0
- nthlayer_workers/observe/dependencies/discovery.py +294 -0
- nthlayer_workers/observe/dependencies/providers/__init__.py +48 -0
- nthlayer_workers/observe/dependencies/providers/backstage.py +467 -0
- nthlayer_workers/observe/dependencies/providers/base.py +76 -0
- nthlayer_workers/observe/dependencies/providers/consul.py +518 -0
- nthlayer_workers/observe/dependencies/providers/etcd.py +360 -0
- nthlayer_workers/observe/dependencies/providers/kubernetes.py +682 -0
- nthlayer_workers/observe/dependencies/providers/prometheus.py +368 -0
- nthlayer_workers/observe/dependencies/providers/zookeeper.py +399 -0
- nthlayer_workers/observe/deployments/__init__.py +1 -0
- nthlayer_workers/observe/discovery/__init__.py +14 -0
- nthlayer_workers/observe/discovery/classifier.py +66 -0
- nthlayer_workers/observe/discovery/client.py +189 -0
- nthlayer_workers/observe/discovery/models.py +53 -0
- nthlayer_workers/observe/drift/__init__.py +26 -0
- nthlayer_workers/observe/drift/analyzer.py +383 -0
- nthlayer_workers/observe/drift/models.py +174 -0
- nthlayer_workers/observe/drift/patterns.py +88 -0
- nthlayer_workers/observe/explanation.py +118 -0
- nthlayer_workers/observe/gate/__init__.py +39 -0
- nthlayer_workers/observe/gate/conditions.py +92 -0
- nthlayer_workers/observe/gate/correlator.py +154 -0
- nthlayer_workers/observe/gate/evaluator.py +192 -0
- nthlayer_workers/observe/gate/policies.py +226 -0
- nthlayer_workers/observe/gate_adapter.py +40 -0
- nthlayer_workers/observe/incident.py +36 -0
- nthlayer_workers/observe/portfolio/__init__.py +17 -0
- nthlayer_workers/observe/portfolio/aggregator.py +168 -0
- nthlayer_workers/observe/portfolio/scorer.py +13 -0
- nthlayer_workers/observe/slo/__init__.py +19 -0
- nthlayer_workers/observe/slo/collector.py +235 -0
- nthlayer_workers/observe/slo/spec_loader.py +40 -0
- nthlayer_workers/observe/sqlite_store.py +152 -0
- nthlayer_workers/observe/store.py +92 -0
- nthlayer_workers/observe/verification/__init__.py +22 -0
- nthlayer_workers/observe/verification/exporter_guidance.py +146 -0
- nthlayer_workers/observe/verification/extractor.py +127 -0
- nthlayer_workers/observe/verification/models.py +101 -0
- nthlayer_workers/observe/verification/verifier.py +111 -0
- nthlayer_workers/observe/worker.py +332 -0
- nthlayer_workers/respond/__init__.py +2 -0
- nthlayer_workers/respond/__main__.py +4 -0
- nthlayer_workers/respond/agents/__init__.py +0 -0
- nthlayer_workers/respond/agents/base.py +556 -0
- nthlayer_workers/respond/agents/communication.py +115 -0
- nthlayer_workers/respond/agents/investigation.py +124 -0
- nthlayer_workers/respond/agents/remediation.py +219 -0
- nthlayer_workers/respond/agents/triage.py +132 -0
- nthlayer_workers/respond/cli.py +772 -0
- nthlayer_workers/respond/config.py +135 -0
- nthlayer_workers/respond/context_store.py +256 -0
- nthlayer_workers/respond/coordinator.py +487 -0
- nthlayer_workers/respond/metrics.py +104 -0
- nthlayer_workers/respond/notification_backends/__init__.py +1 -0
- nthlayer_workers/respond/notification_backends/ntfy_backend.py +158 -0
- nthlayer_workers/respond/notification_backends/protocol.py +59 -0
- nthlayer_workers/respond/notification_backends/slack_backend.py +203 -0
- nthlayer_workers/respond/notification_backends/stdout_backend.py +56 -0
- nthlayer_workers/respond/notifications.py +247 -0
- nthlayer_workers/respond/oncall/__init__.py +1 -0
- nthlayer_workers/respond/oncall/escalation.py +103 -0
- nthlayer_workers/respond/oncall/runner.py +193 -0
- nthlayer_workers/respond/oncall/schedule.py +243 -0
- nthlayer_workers/respond/safe_actions/__init__.py +0 -0
- nthlayer_workers/respond/safe_actions/actions.py +139 -0
- nthlayer_workers/respond/safe_actions/registry.py +171 -0
- nthlayer_workers/respond/safe_actions/webhook.py +194 -0
- nthlayer_workers/respond/server.py +357 -0
- nthlayer_workers/respond/sre/__init__.py +1 -0
- nthlayer_workers/respond/sre/brief.py +175 -0
- nthlayer_workers/respond/sre/delegation.py +101 -0
- nthlayer_workers/respond/sre/post_incident.py +146 -0
- nthlayer_workers/respond/sre/shift_report.py +129 -0
- nthlayer_workers/respond/sre/suppression.py +91 -0
- nthlayer_workers/respond/types.py +109 -0
- nthlayer_workers/respond/verdict_submission.py +56 -0
- nthlayer_workers/respond/worker.py +533 -0
- nthlayer_workers/respond/worker_helpers.py +140 -0
- nthlayer_workers/runner.py +198 -0
- nthlayer_workers-1.0.0.dist-info/METADATA +19 -0
- nthlayer_workers-1.0.0.dist-info/RECORD +175 -0
- nthlayer_workers-1.0.0.dist-info/WHEEL +5 -0
- nthlayer_workers-1.0.0.dist-info/entry_points.txt +2 -0
- nthlayer_workers-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
"""Webhook dispatcher for safe action execution bindings.
|
|
2
|
+
|
|
3
|
+
Renders {{variable}} templates, resolves ${ENV_VAR} secrets,
|
|
4
|
+
makes HTTP calls, and optionally verifies results via PromQL.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass
|
|
21
|
+
class ExecutionResult:
|
|
22
|
+
"""Result of a safe action execution."""
|
|
23
|
+
|
|
24
|
+
success: bool
|
|
25
|
+
status_code: int | None = None
|
|
26
|
+
detail: str = ""
|
|
27
|
+
verified: bool | None = None
|
|
28
|
+
verification_detail: str | None = None
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def render_binding_templates(obj: Any, variables: dict[str, str]) -> Any:
|
|
32
|
+
"""Recursively render {{variable}} placeholders in strings."""
|
|
33
|
+
if isinstance(obj, str):
|
|
34
|
+
for key, value in variables.items():
|
|
35
|
+
obj = obj.replace("{{" + key + "}}", str(value))
|
|
36
|
+
obj = obj.replace("{{ " + key + " }}", str(value))
|
|
37
|
+
return obj
|
|
38
|
+
if isinstance(obj, dict):
|
|
39
|
+
return {k: render_binding_templates(v, variables) for k, v in obj.items()}
|
|
40
|
+
if isinstance(obj, list):
|
|
41
|
+
return [render_binding_templates(item, variables) for item in obj]
|
|
42
|
+
return obj
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def resolve_secrets(obj: Any) -> Any:
|
|
46
|
+
"""Recursively resolve ${ENV_VAR} placeholders from os.environ.
|
|
47
|
+
|
|
48
|
+
Raises ValueError if a referenced env var is not set.
|
|
49
|
+
"""
|
|
50
|
+
if isinstance(obj, str):
|
|
51
|
+
def _replace(match):
|
|
52
|
+
var_name = match.group(1)
|
|
53
|
+
value = os.environ.get(var_name)
|
|
54
|
+
if value is None:
|
|
55
|
+
raise ValueError(
|
|
56
|
+
f"Secret ${{{var_name}}} not set. "
|
|
57
|
+
f"Set the {var_name} environment variable."
|
|
58
|
+
)
|
|
59
|
+
return value
|
|
60
|
+
return re.sub(r'\$\{(\w+)\}', _replace, obj)
|
|
61
|
+
if isinstance(obj, dict):
|
|
62
|
+
return {k: resolve_secrets(v) for k, v in obj.items()}
|
|
63
|
+
if isinstance(obj, list):
|
|
64
|
+
return [resolve_secrets(item) for item in obj]
|
|
65
|
+
return obj
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class WebhookDispatcher:
|
|
69
|
+
"""Execute safe action bindings via HTTP webhooks."""
|
|
70
|
+
|
|
71
|
+
async def execute(
|
|
72
|
+
self, binding: dict | str, variables: dict[str, str]
|
|
73
|
+
) -> ExecutionResult:
|
|
74
|
+
"""Render templates, resolve secrets, make HTTP call, verify."""
|
|
75
|
+
if binding == "stub" or not binding:
|
|
76
|
+
target = variables.get("service", variables.get("target", "unknown"))
|
|
77
|
+
return ExecutionResult(
|
|
78
|
+
success=True,
|
|
79
|
+
detail=f"Stub execution for {target} (no binding configured).",
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
rendered = render_binding_templates(binding, variables)
|
|
83
|
+
try:
|
|
84
|
+
rendered = resolve_secrets(rendered)
|
|
85
|
+
except ValueError as exc:
|
|
86
|
+
return ExecutionResult(success=False, detail=str(exc))
|
|
87
|
+
|
|
88
|
+
url = rendered.get("url", "")
|
|
89
|
+
headers = rendered.get("headers", {})
|
|
90
|
+
body = rendered.get("body")
|
|
91
|
+
timeout = int(rendered.get("timeout", 30))
|
|
92
|
+
retry_config = rendered.get("retry", {})
|
|
93
|
+
verify_config = rendered.get("verify_after")
|
|
94
|
+
|
|
95
|
+
result = await self._call_webhook(url, headers, body, timeout, retry_config)
|
|
96
|
+
|
|
97
|
+
if verify_config and result.success:
|
|
98
|
+
verification = await self._verify(verify_config, variables)
|
|
99
|
+
result.verified = verification.verified
|
|
100
|
+
result.verification_detail = verification.verification_detail
|
|
101
|
+
|
|
102
|
+
return result
|
|
103
|
+
|
|
104
|
+
async def _call_webhook(
|
|
105
|
+
self, url, headers, body, timeout, retry_config
|
|
106
|
+
) -> ExecutionResult:
|
|
107
|
+
"""Make HTTP POST with retry logic."""
|
|
108
|
+
attempts = retry_config.get("attempts", 1)
|
|
109
|
+
backoff = retry_config.get("backoff", [1])
|
|
110
|
+
last_error = ""
|
|
111
|
+
last_status = None
|
|
112
|
+
|
|
113
|
+
async with httpx.AsyncClient() as client:
|
|
114
|
+
for attempt in range(attempts):
|
|
115
|
+
try:
|
|
116
|
+
resp = await client.post(
|
|
117
|
+
url, headers=headers, json=body, timeout=timeout
|
|
118
|
+
)
|
|
119
|
+
last_status = resp.status_code
|
|
120
|
+
if resp.is_success:
|
|
121
|
+
return ExecutionResult(
|
|
122
|
+
success=True,
|
|
123
|
+
status_code=resp.status_code,
|
|
124
|
+
detail=resp.text[:500],
|
|
125
|
+
)
|
|
126
|
+
resp.raise_for_status()
|
|
127
|
+
except httpx.HTTPStatusError as exc:
|
|
128
|
+
last_error = f"HTTP {exc.response.status_code}: {exc.response.text[:200]}"
|
|
129
|
+
last_status = exc.response.status_code
|
|
130
|
+
except httpx.TimeoutException:
|
|
131
|
+
last_error = f"Timeout after {timeout}s"
|
|
132
|
+
except Exception as exc:
|
|
133
|
+
last_error = str(exc)
|
|
134
|
+
|
|
135
|
+
if attempt < attempts - 1:
|
|
136
|
+
delay = backoff[min(attempt, len(backoff) - 1)]
|
|
137
|
+
await asyncio.sleep(delay)
|
|
138
|
+
|
|
139
|
+
return ExecutionResult(
|
|
140
|
+
success=False, status_code=last_status, detail=last_error
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
async def _verify(self, verify_config, variables) -> ExecutionResult:
|
|
144
|
+
"""Wait, query Prometheus, return verification result."""
|
|
145
|
+
wait = int(verify_config.get("wait", 30))
|
|
146
|
+
query = verify_config.get("query", "")
|
|
147
|
+
description = verify_config.get("description", "")
|
|
148
|
+
prometheus_url = verify_config.get("prometheus_url") or os.environ.get(
|
|
149
|
+
"PROMETHEUS_URL", "http://localhost:9090"
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
query = render_binding_templates(query, variables)
|
|
153
|
+
|
|
154
|
+
logger.info("Waiting %ds before verification: %s", wait, description)
|
|
155
|
+
await asyncio.sleep(wait)
|
|
156
|
+
|
|
157
|
+
try:
|
|
158
|
+
async with httpx.AsyncClient() as client:
|
|
159
|
+
resp = await client.get(
|
|
160
|
+
f"{prometheus_url}/api/v1/query",
|
|
161
|
+
params={"query": query},
|
|
162
|
+
timeout=10.0,
|
|
163
|
+
)
|
|
164
|
+
resp.raise_for_status()
|
|
165
|
+
data = resp.json()
|
|
166
|
+
results = data.get("data", {}).get("result", [])
|
|
167
|
+
|
|
168
|
+
if not results:
|
|
169
|
+
return ExecutionResult(
|
|
170
|
+
success=True,
|
|
171
|
+
verified=None,
|
|
172
|
+
verification_detail=f"No data for query: {description}",
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
value = float(results[0].get("value", [None, "0"])[1])
|
|
176
|
+
verified = value == 1.0
|
|
177
|
+
|
|
178
|
+
return ExecutionResult(
|
|
179
|
+
success=True,
|
|
180
|
+
verified=verified,
|
|
181
|
+
verification_detail=(
|
|
182
|
+
f"Verified: {description}"
|
|
183
|
+
if verified
|
|
184
|
+
else f"Verification failed: {description} (value={value})"
|
|
185
|
+
),
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
except Exception as exc:
|
|
189
|
+
logger.warning("Verification query failed: %s", exc)
|
|
190
|
+
return ExecutionResult(
|
|
191
|
+
success=True,
|
|
192
|
+
verified=None,
|
|
193
|
+
verification_detail=f"Verification unavailable: {exc}",
|
|
194
|
+
)
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
"""HTTP server for incident approval workflows.
|
|
2
|
+
|
|
3
|
+
Starlette ASGI app with routes for approve, reject, status, and
|
|
4
|
+
Slack interaction callbacks. Embedded in `nthlayer-respond serve`.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import contextlib
|
|
10
|
+
import json
|
|
11
|
+
import logging
|
|
12
|
+
import time as _time
|
|
13
|
+
from collections.abc import AsyncGenerator
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from starlette.applications import Starlette
|
|
18
|
+
from starlette.requests import Request
|
|
19
|
+
from starlette.responses import JSONResponse, Response
|
|
20
|
+
from starlette.routing import Route
|
|
21
|
+
|
|
22
|
+
from nthlayer_common.slack_web import SlackWebClient
|
|
23
|
+
from nthlayer_workers.respond.config import RespondConfig
|
|
24
|
+
from nthlayer_workers.respond.metrics import VerdictMetricsCollector
|
|
25
|
+
from nthlayer_workers.respond.types import IncidentState
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ApprovalServer:
|
|
31
|
+
"""HTTP server for incident approval workflows."""
|
|
32
|
+
|
|
33
|
+
def __init__(
|
|
34
|
+
self,
|
|
35
|
+
coordinator: Any,
|
|
36
|
+
context_store: Any,
|
|
37
|
+
config: RespondConfig,
|
|
38
|
+
verdict_store: Any = None,
|
|
39
|
+
) -> None:
|
|
40
|
+
self._coordinator = coordinator
|
|
41
|
+
self._context_store = context_store
|
|
42
|
+
self._config = config
|
|
43
|
+
self._timeouts: dict[str, asyncio.Task] = {}
|
|
44
|
+
self._locks: dict[str, asyncio.Lock] = {}
|
|
45
|
+
self._metrics = VerdictMetricsCollector(verdict_store) if verdict_store else None
|
|
46
|
+
|
|
47
|
+
def _get_lock(self, incident_id: str) -> asyncio.Lock:
|
|
48
|
+
"""Get or create a per-incident lock to serialize approve/reject/timeout."""
|
|
49
|
+
if incident_id not in self._locks:
|
|
50
|
+
self._locks[incident_id] = asyncio.Lock()
|
|
51
|
+
return self._locks[incident_id]
|
|
52
|
+
|
|
53
|
+
@contextlib.asynccontextmanager
|
|
54
|
+
async def _lifespan(self, app: Starlette) -> AsyncGenerator[None, None]:
|
|
55
|
+
"""Recover pending approval timeouts on startup."""
|
|
56
|
+
await self.recover_pending_approvals()
|
|
57
|
+
yield
|
|
58
|
+
|
|
59
|
+
def build_app(self) -> Starlette:
|
|
60
|
+
"""Build the Starlette ASGI application."""
|
|
61
|
+
routes = [
|
|
62
|
+
Route(
|
|
63
|
+
"/api/v1/incidents/{incident_id}/approve",
|
|
64
|
+
self.handle_approve,
|
|
65
|
+
methods=["POST"],
|
|
66
|
+
),
|
|
67
|
+
Route(
|
|
68
|
+
"/api/v1/incidents/{incident_id}/reject",
|
|
69
|
+
self.handle_reject,
|
|
70
|
+
methods=["POST"],
|
|
71
|
+
),
|
|
72
|
+
Route(
|
|
73
|
+
"/api/v1/incidents/{incident_id}",
|
|
74
|
+
self.handle_status,
|
|
75
|
+
methods=["GET"],
|
|
76
|
+
),
|
|
77
|
+
Route(
|
|
78
|
+
"/api/v1/slack/interactions",
|
|
79
|
+
self.handle_slack_interaction,
|
|
80
|
+
methods=["POST"],
|
|
81
|
+
),
|
|
82
|
+
Route("/metrics", self.handle_metrics, methods=["GET"]),
|
|
83
|
+
]
|
|
84
|
+
return Starlette(routes=routes, lifespan=self._lifespan)
|
|
85
|
+
|
|
86
|
+
async def handle_approve(self, request: Request) -> JSONResponse:
|
|
87
|
+
"""POST /api/v1/incidents/{id}/approve"""
|
|
88
|
+
incident_id = request.path_params["incident_id"]
|
|
89
|
+
try:
|
|
90
|
+
body = await request.json() if await request.body() else {}
|
|
91
|
+
except (json.JSONDecodeError, ValueError):
|
|
92
|
+
return JSONResponse({"error": "Invalid JSON body"}, status_code=400)
|
|
93
|
+
approved_by = body.get("approved_by")
|
|
94
|
+
|
|
95
|
+
async with self._get_lock(incident_id):
|
|
96
|
+
try:
|
|
97
|
+
ctx = await self._coordinator.approve(
|
|
98
|
+
incident_id, approved_by=approved_by
|
|
99
|
+
)
|
|
100
|
+
except ValueError as exc:
|
|
101
|
+
msg = str(exc)
|
|
102
|
+
if "not found" in msg.lower():
|
|
103
|
+
return JSONResponse({"error": msg}, status_code=404)
|
|
104
|
+
return JSONResponse({"error": msg}, status_code=409)
|
|
105
|
+
|
|
106
|
+
self.cancel_timeout(incident_id)
|
|
107
|
+
|
|
108
|
+
return JSONResponse({
|
|
109
|
+
"incident_id": ctx.id,
|
|
110
|
+
"state": ctx.state.value,
|
|
111
|
+
"action": ctx.remediation.proposed_action if ctx.remediation else None,
|
|
112
|
+
"target": ctx.remediation.target if ctx.remediation else None,
|
|
113
|
+
"approved_by": approved_by,
|
|
114
|
+
"execution_result": ctx.remediation.execution_result if ctx.remediation else None,
|
|
115
|
+
"verdict_id": ctx.verdict_chain[-1] if ctx.verdict_chain else None,
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
async def handle_reject(self, request: Request) -> JSONResponse:
|
|
119
|
+
"""POST /api/v1/incidents/{id}/reject"""
|
|
120
|
+
incident_id = request.path_params["incident_id"]
|
|
121
|
+
try:
|
|
122
|
+
body = await request.json() if await request.body() else {}
|
|
123
|
+
except (json.JSONDecodeError, ValueError):
|
|
124
|
+
return JSONResponse({"error": "Invalid JSON body"}, status_code=400)
|
|
125
|
+
reason = body.get("reason")
|
|
126
|
+
rejected_by = body.get("rejected_by")
|
|
127
|
+
|
|
128
|
+
if not reason:
|
|
129
|
+
return JSONResponse(
|
|
130
|
+
{"error": "reason is required"}, status_code=400
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
async with self._get_lock(incident_id):
|
|
134
|
+
try:
|
|
135
|
+
ctx = await self._coordinator.reject(
|
|
136
|
+
incident_id, reason, rejected_by=rejected_by
|
|
137
|
+
)
|
|
138
|
+
except ValueError as exc:
|
|
139
|
+
msg = str(exc)
|
|
140
|
+
if "not found" in msg.lower():
|
|
141
|
+
return JSONResponse({"error": msg}, status_code=404)
|
|
142
|
+
return JSONResponse({"error": msg}, status_code=409)
|
|
143
|
+
|
|
144
|
+
self.cancel_timeout(incident_id)
|
|
145
|
+
|
|
146
|
+
return JSONResponse({
|
|
147
|
+
"incident_id": ctx.id,
|
|
148
|
+
"state": ctx.state.value,
|
|
149
|
+
"rejected_by": rejected_by,
|
|
150
|
+
"reason": reason,
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
async def handle_status(self, request: Request) -> JSONResponse:
|
|
154
|
+
"""GET /api/v1/incidents/{id}"""
|
|
155
|
+
incident_id = request.path_params["incident_id"]
|
|
156
|
+
ctx = self._context_store.load(incident_id)
|
|
157
|
+
|
|
158
|
+
if ctx is None:
|
|
159
|
+
return JSONResponse(
|
|
160
|
+
{"error": f"Incident {incident_id!r} not found"}, status_code=404
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
result: dict[str, Any] = {
|
|
164
|
+
"incident_id": ctx.id,
|
|
165
|
+
"state": ctx.state.value,
|
|
166
|
+
"created_at": ctx.created_at,
|
|
167
|
+
"updated_at": ctx.updated_at,
|
|
168
|
+
"trigger_source": ctx.trigger_source,
|
|
169
|
+
}
|
|
170
|
+
if ctx.remediation:
|
|
171
|
+
result["proposed_action"] = ctx.remediation.proposed_action
|
|
172
|
+
result["target"] = ctx.remediation.target
|
|
173
|
+
result["requires_human_approval"] = ctx.remediation.requires_human_approval
|
|
174
|
+
result["executed"] = ctx.remediation.executed
|
|
175
|
+
if ctx.triage:
|
|
176
|
+
result["severity"] = ctx.triage.severity
|
|
177
|
+
return JSONResponse(result)
|
|
178
|
+
|
|
179
|
+
async def handle_slack_interaction(self, request: Request) -> Response:
|
|
180
|
+
"""POST /api/v1/slack/interactions — Slack callback endpoint."""
|
|
181
|
+
signing_secret = self._config.slack_signing_secret
|
|
182
|
+
if not signing_secret:
|
|
183
|
+
return Response(
|
|
184
|
+
content=json.dumps({"error": "Slack signing secret not configured"}),
|
|
185
|
+
status_code=403,
|
|
186
|
+
media_type="application/json",
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
body = await request.body()
|
|
190
|
+
timestamp = request.headers.get("X-Slack-Request-Timestamp", "")
|
|
191
|
+
signature = request.headers.get("X-Slack-Signature", "")
|
|
192
|
+
|
|
193
|
+
if not SlackWebClient.verify_signature(signing_secret, timestamp, body, signature):
|
|
194
|
+
return Response(status_code=401)
|
|
195
|
+
|
|
196
|
+
# Slack sends payload as form-encoded
|
|
197
|
+
form = await request.form()
|
|
198
|
+
payload_str = form.get("payload", "")
|
|
199
|
+
try:
|
|
200
|
+
payload = json.loads(payload_str)
|
|
201
|
+
except (json.JSONDecodeError, TypeError):
|
|
202
|
+
return Response(status_code=400)
|
|
203
|
+
|
|
204
|
+
actions = payload.get("actions", [])
|
|
205
|
+
if not actions:
|
|
206
|
+
return Response(status_code=200)
|
|
207
|
+
|
|
208
|
+
action = actions[0]
|
|
209
|
+
action_id = action.get("action_id")
|
|
210
|
+
incident_id = action.get("value")
|
|
211
|
+
if not incident_id:
|
|
212
|
+
return Response(status_code=200)
|
|
213
|
+
|
|
214
|
+
user = payload.get("user") or {}
|
|
215
|
+
user_name = user.get("name", "unknown") if isinstance(user, dict) else "unknown"
|
|
216
|
+
channel = payload.get("channel") or {}
|
|
217
|
+
channel_id = channel.get("id") if isinstance(channel, dict) else None
|
|
218
|
+
message = payload.get("message") or {}
|
|
219
|
+
message_ts = message.get("ts") if isinstance(message, dict) else None
|
|
220
|
+
|
|
221
|
+
async with self._get_lock(incident_id):
|
|
222
|
+
try:
|
|
223
|
+
if action_id == "approve":
|
|
224
|
+
ctx = await self._coordinator.approve(
|
|
225
|
+
incident_id, approved_by=user_name
|
|
226
|
+
)
|
|
227
|
+
elif action_id == "reject":
|
|
228
|
+
ctx = await self._coordinator.reject(
|
|
229
|
+
incident_id,
|
|
230
|
+
f"Rejected via Slack by {user_name}",
|
|
231
|
+
rejected_by=user_name,
|
|
232
|
+
)
|
|
233
|
+
else:
|
|
234
|
+
return Response(status_code=200)
|
|
235
|
+
except ValueError as exc:
|
|
236
|
+
logger.warning("Slack interaction failed: %s", exc)
|
|
237
|
+
return Response(status_code=200) # Slack expects 200
|
|
238
|
+
|
|
239
|
+
self.cancel_timeout(incident_id)
|
|
240
|
+
|
|
241
|
+
# Update original message to remove buttons (fire-and-forget)
|
|
242
|
+
if self._config.slack_bot_token and channel_id and message_ts:
|
|
243
|
+
asyncio.create_task(
|
|
244
|
+
self._update_slack_message(
|
|
245
|
+
channel_id, message_ts, action_id, user_name, ctx
|
|
246
|
+
)
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
return Response(status_code=200)
|
|
250
|
+
|
|
251
|
+
async def handle_metrics(self, request: Request) -> Response:
|
|
252
|
+
"""GET /metrics — Prometheus text exposition."""
|
|
253
|
+
if self._metrics is None:
|
|
254
|
+
return Response(content="", media_type="text/plain")
|
|
255
|
+
text = self._metrics.collect()
|
|
256
|
+
return Response(
|
|
257
|
+
content=text,
|
|
258
|
+
media_type="text/plain; version=0.0.4; charset=utf-8",
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
async def _update_slack_message(
|
|
262
|
+
self,
|
|
263
|
+
channel_id: str,
|
|
264
|
+
message_ts: str,
|
|
265
|
+
action_id: str,
|
|
266
|
+
user_name: str,
|
|
267
|
+
ctx: Any,
|
|
268
|
+
) -> None:
|
|
269
|
+
"""Replace buttons with confirmation text in the original Slack message."""
|
|
270
|
+
client = SlackWebClient(self._config.slack_bot_token)
|
|
271
|
+
|
|
272
|
+
if action_id == "approve":
|
|
273
|
+
status_text = f"\u2705 Approved by @{user_name}"
|
|
274
|
+
else:
|
|
275
|
+
status_text = f"\u274c Rejected by @{user_name}"
|
|
276
|
+
|
|
277
|
+
blocks = [
|
|
278
|
+
{"type": "section", "text": {"type": "mrkdwn", "text": f"*{status_text}*"}},
|
|
279
|
+
{"type": "context", "elements": [
|
|
280
|
+
{"type": "mrkdwn", "text": f"State: {ctx.state.value} \u00b7 nthlayer-respond"},
|
|
281
|
+
]},
|
|
282
|
+
]
|
|
283
|
+
|
|
284
|
+
try:
|
|
285
|
+
await client.update_message(channel_id, message_ts, blocks, status_text)
|
|
286
|
+
except Exception as exc:
|
|
287
|
+
logger.warning("Slack message update failed: %s", exc)
|
|
288
|
+
|
|
289
|
+
def start_timeout(self, incident_id: str) -> None:
|
|
290
|
+
"""Start a background timeout task for an incident."""
|
|
291
|
+
self.cancel_timeout(incident_id)
|
|
292
|
+
task = asyncio.create_task(self._timeout_task(incident_id))
|
|
293
|
+
self._timeouts[incident_id] = task
|
|
294
|
+
|
|
295
|
+
def cancel_timeout(self, incident_id: str) -> None:
|
|
296
|
+
"""Cancel the timeout task for an incident if active."""
|
|
297
|
+
task = self._timeouts.pop(incident_id, None)
|
|
298
|
+
if task and not task.done():
|
|
299
|
+
task.cancel()
|
|
300
|
+
self._locks.pop(incident_id, None)
|
|
301
|
+
|
|
302
|
+
async def _timeout_task(self, incident_id: str, delay: float | None = None) -> None:
|
|
303
|
+
"""Wait for delay seconds, then auto-reject if still awaiting approval.
|
|
304
|
+
|
|
305
|
+
If delay is None, uses approval_timeout_seconds from config.
|
|
306
|
+
"""
|
|
307
|
+
wait = delay if delay is not None else self._config.approval_timeout_seconds
|
|
308
|
+
try:
|
|
309
|
+
await asyncio.sleep(wait)
|
|
310
|
+
except asyncio.CancelledError:
|
|
311
|
+
return
|
|
312
|
+
|
|
313
|
+
async with self._get_lock(incident_id):
|
|
314
|
+
ctx = self._context_store.load(incident_id)
|
|
315
|
+
if ctx is None or ctx.state != IncidentState.AWAITING_APPROVAL:
|
|
316
|
+
return
|
|
317
|
+
|
|
318
|
+
try:
|
|
319
|
+
await self._coordinator.reject(
|
|
320
|
+
incident_id,
|
|
321
|
+
f"Approval timed out after {self._config.approval_timeout_seconds}s",
|
|
322
|
+
rejected_by="system/timeout",
|
|
323
|
+
)
|
|
324
|
+
logger.info("Approval timed out", extra={"incident_id": incident_id})
|
|
325
|
+
except Exception as exc:
|
|
326
|
+
logger.warning("Timeout reject failed: %s", exc)
|
|
327
|
+
finally:
|
|
328
|
+
self._timeouts.pop(incident_id, None)
|
|
329
|
+
|
|
330
|
+
async def recover_pending_approvals(self) -> None:
|
|
331
|
+
"""On startup, scan for AWAITING_APPROVAL incidents and start timeouts."""
|
|
332
|
+
active = self._context_store.list_active()
|
|
333
|
+
for incident_id in active:
|
|
334
|
+
ctx = self._context_store.load(incident_id)
|
|
335
|
+
if ctx is None or ctx.state != IncidentState.AWAITING_APPROVAL:
|
|
336
|
+
continue
|
|
337
|
+
|
|
338
|
+
try:
|
|
339
|
+
updated = datetime.fromisoformat(ctx.updated_at)
|
|
340
|
+
elapsed = _time.time() - updated.replace(tzinfo=timezone.utc).timestamp()
|
|
341
|
+
remaining = self._config.approval_timeout_seconds - elapsed
|
|
342
|
+
except (ValueError, TypeError):
|
|
343
|
+
remaining = self._config.approval_timeout_seconds
|
|
344
|
+
|
|
345
|
+
if remaining <= 0:
|
|
346
|
+
try:
|
|
347
|
+
await self._coordinator.reject(
|
|
348
|
+
incident_id,
|
|
349
|
+
"Approval timed out (expired during server downtime)",
|
|
350
|
+
rejected_by="system/timeout",
|
|
351
|
+
)
|
|
352
|
+
except Exception as exc:
|
|
353
|
+
logger.warning("Timeout recovery reject failed: %s", exc)
|
|
354
|
+
else:
|
|
355
|
+
self.cancel_timeout(incident_id)
|
|
356
|
+
task = asyncio.create_task(self._timeout_task(incident_id, delay=remaining))
|
|
357
|
+
self._timeouts[incident_id] = task
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""SRE experience features — templates over verdicts."""
|