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,226 @@
|
|
|
1
|
+
"""Policy condition evaluator.
|
|
2
|
+
|
|
3
|
+
Parses and evaluates condition strings against a context dictionary.
|
|
4
|
+
Supports a simple DSL for time, SLO, and service-based conditions.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from typing import Any, Callable
|
|
13
|
+
|
|
14
|
+
from nthlayer_workers.observe.gate.conditions import (
|
|
15
|
+
is_business_hours,
|
|
16
|
+
is_freeze_period,
|
|
17
|
+
is_peak_traffic,
|
|
18
|
+
is_weekday,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class PolicyContext:
|
|
24
|
+
"""Context for policy evaluation with service and SLO data."""
|
|
25
|
+
|
|
26
|
+
budget_remaining: float = 100.0
|
|
27
|
+
budget_consumed: float = 0.0
|
|
28
|
+
burn_rate: float = 1.0
|
|
29
|
+
tier: str = "standard"
|
|
30
|
+
environment: str = "prod"
|
|
31
|
+
service: str = ""
|
|
32
|
+
team: str = ""
|
|
33
|
+
downstream_count: int = 0
|
|
34
|
+
high_criticality_downstream: int = 0
|
|
35
|
+
now: datetime | None = None
|
|
36
|
+
|
|
37
|
+
def to_dict(self) -> dict[str, Any]:
|
|
38
|
+
"""Convert to dictionary for evaluation. Delegates to get_current_context."""
|
|
39
|
+
from nthlayer_workers.observe.gate.conditions import get_current_context
|
|
40
|
+
|
|
41
|
+
ctx = get_current_context(
|
|
42
|
+
budget_remaining=self.budget_remaining,
|
|
43
|
+
budget_consumed=self.budget_consumed,
|
|
44
|
+
burn_rate=self.burn_rate,
|
|
45
|
+
tier=self.tier,
|
|
46
|
+
environment=self.environment,
|
|
47
|
+
downstream_count=self.downstream_count,
|
|
48
|
+
high_criticality_downstream=self.high_criticality_downstream,
|
|
49
|
+
now=self.now,
|
|
50
|
+
)
|
|
51
|
+
ctx["service"] = self.service
|
|
52
|
+
ctx["team"] = self.team
|
|
53
|
+
return ctx
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@dataclass
|
|
57
|
+
class EvaluationResult:
|
|
58
|
+
"""Result of condition evaluation."""
|
|
59
|
+
|
|
60
|
+
condition: str
|
|
61
|
+
result: bool
|
|
62
|
+
matched_rule: str | None = None
|
|
63
|
+
context_snapshot: dict[str, Any] = field(default_factory=dict)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class ConditionEvaluator:
|
|
67
|
+
"""Evaluates policy conditions against a context.
|
|
68
|
+
|
|
69
|
+
Supports comparisons, boolean operators (AND/OR/NOT), parentheses,
|
|
70
|
+
and built-in functions (business_hours, freeze_period, etc.).
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
OPERATORS: dict[str, Any] = {
|
|
74
|
+
"==": lambda a, b: a == b,
|
|
75
|
+
"!=": lambda a, b: a != b,
|
|
76
|
+
">=": lambda a, b: a >= b,
|
|
77
|
+
"<=": lambda a, b: a <= b,
|
|
78
|
+
">": lambda a, b: a > b,
|
|
79
|
+
"<": lambda a, b: a < b,
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
FUNCTIONS: dict[str, Callable[..., bool]] = {
|
|
83
|
+
"business_hours": is_business_hours,
|
|
84
|
+
"weekday": is_weekday,
|
|
85
|
+
"freeze_period": is_freeze_period,
|
|
86
|
+
"peak_traffic": is_peak_traffic,
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
def __init__(self, context: PolicyContext | dict[str, Any] | None = None):
|
|
90
|
+
self._policy_context: PolicyContext | None = None
|
|
91
|
+
if context is None:
|
|
92
|
+
self.context: dict[str, Any] = {}
|
|
93
|
+
elif isinstance(context, PolicyContext):
|
|
94
|
+
self.context = context.to_dict()
|
|
95
|
+
self._policy_context = context
|
|
96
|
+
else:
|
|
97
|
+
self.context = context
|
|
98
|
+
|
|
99
|
+
def evaluate(self, condition: str) -> bool:
|
|
100
|
+
"""Evaluate a condition string. Empty condition = always true."""
|
|
101
|
+
if not condition or not condition.strip():
|
|
102
|
+
return True
|
|
103
|
+
|
|
104
|
+
condition = " ".join(condition.split())
|
|
105
|
+
|
|
106
|
+
try:
|
|
107
|
+
return self._evaluate_expression(condition)
|
|
108
|
+
except Exception:
|
|
109
|
+
return False # fail safe
|
|
110
|
+
|
|
111
|
+
def _evaluate_expression(self, expr: str) -> bool:
|
|
112
|
+
"""Evaluate a boolean expression with AND/OR/NOT."""
|
|
113
|
+
expr = expr.strip()
|
|
114
|
+
|
|
115
|
+
while "(" in expr:
|
|
116
|
+
match = re.search(r"(?<!\w)\(([^()]+)\)", expr)
|
|
117
|
+
if match:
|
|
118
|
+
inner = match.group(1)
|
|
119
|
+
result = self._evaluate_expression(inner)
|
|
120
|
+
expr = expr[: match.start()] + str(result) + expr[match.end() :]
|
|
121
|
+
else:
|
|
122
|
+
break
|
|
123
|
+
|
|
124
|
+
if expr.upper().startswith("NOT "):
|
|
125
|
+
return not self._evaluate_expression(expr[4:])
|
|
126
|
+
|
|
127
|
+
if " OR " in expr.upper():
|
|
128
|
+
parts = re.split(r"\s+OR\s+", expr, flags=re.IGNORECASE)
|
|
129
|
+
return any(self._evaluate_expression(p) for p in parts)
|
|
130
|
+
|
|
131
|
+
if " AND " in expr.upper():
|
|
132
|
+
parts = re.split(r"\s+AND\s+", expr, flags=re.IGNORECASE)
|
|
133
|
+
return all(self._evaluate_expression(p) for p in parts)
|
|
134
|
+
|
|
135
|
+
if expr.lower() == "true":
|
|
136
|
+
return True
|
|
137
|
+
if expr.lower() == "false":
|
|
138
|
+
return False
|
|
139
|
+
|
|
140
|
+
func_match = re.match(r"(\w+)\((.*)\)", expr)
|
|
141
|
+
if func_match:
|
|
142
|
+
return self._evaluate_function(func_match.group(1), func_match.group(2))
|
|
143
|
+
|
|
144
|
+
return self._evaluate_comparison(expr)
|
|
145
|
+
|
|
146
|
+
def _evaluate_comparison(self, expr: str) -> bool:
|
|
147
|
+
"""Evaluate a comparison like 'hour >= 9'."""
|
|
148
|
+
for op, func in self.OPERATORS.items():
|
|
149
|
+
if op in expr:
|
|
150
|
+
parts = expr.split(op, 1)
|
|
151
|
+
if len(parts) == 2:
|
|
152
|
+
left = self._resolve_value(parts[0].strip())
|
|
153
|
+
right = self._resolve_value(parts[1].strip())
|
|
154
|
+
return func(left, right)
|
|
155
|
+
|
|
156
|
+
value = self._resolve_value(expr)
|
|
157
|
+
return bool(value)
|
|
158
|
+
|
|
159
|
+
def _resolve_value(self, token: str) -> Any:
|
|
160
|
+
"""Resolve a token to its value."""
|
|
161
|
+
token = token.strip()
|
|
162
|
+
|
|
163
|
+
if (token.startswith("'") and token.endswith("'")) or (
|
|
164
|
+
token.startswith('"') and token.endswith('"')
|
|
165
|
+
):
|
|
166
|
+
return token[1:-1]
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
if "." in token:
|
|
170
|
+
return float(token)
|
|
171
|
+
return int(token)
|
|
172
|
+
except ValueError:
|
|
173
|
+
pass
|
|
174
|
+
|
|
175
|
+
if token.lower() == "true":
|
|
176
|
+
return True
|
|
177
|
+
if token.lower() == "false":
|
|
178
|
+
return False
|
|
179
|
+
|
|
180
|
+
return self.context.get(token, False)
|
|
181
|
+
|
|
182
|
+
def _evaluate_function(self, name: str, args_str: str) -> bool:
|
|
183
|
+
"""Evaluate a function call."""
|
|
184
|
+
name = name.lower()
|
|
185
|
+
|
|
186
|
+
if name not in self.FUNCTIONS:
|
|
187
|
+
return False
|
|
188
|
+
|
|
189
|
+
func = self.FUNCTIONS[name]
|
|
190
|
+
|
|
191
|
+
args = []
|
|
192
|
+
if args_str.strip():
|
|
193
|
+
raw_args = re.split(r",\s*(?=(?:[^']*'[^']*')*[^']*$)", args_str)
|
|
194
|
+
for arg in raw_args:
|
|
195
|
+
args.append(self._resolve_value(arg.strip()))
|
|
196
|
+
|
|
197
|
+
now = None
|
|
198
|
+
if self._policy_context:
|
|
199
|
+
now = self._policy_context.now
|
|
200
|
+
|
|
201
|
+
if name == "freeze_period" and len(args) >= 2:
|
|
202
|
+
return func(args[0], args[1], now=now)
|
|
203
|
+
elif name in ("business_hours", "weekday", "peak_traffic"):
|
|
204
|
+
return func(now=now)
|
|
205
|
+
|
|
206
|
+
return False
|
|
207
|
+
|
|
208
|
+
def evaluate_all(
|
|
209
|
+
self,
|
|
210
|
+
conditions: list[dict[str, Any]],
|
|
211
|
+
) -> tuple[bool, dict[str, Any] | None]:
|
|
212
|
+
"""Evaluate multiple conditions and return the most restrictive match."""
|
|
213
|
+
matched = None
|
|
214
|
+
|
|
215
|
+
for cond in conditions:
|
|
216
|
+
when_clause = cond.get("when", "")
|
|
217
|
+
if self.evaluate(when_clause):
|
|
218
|
+
if matched is None:
|
|
219
|
+
matched = cond
|
|
220
|
+
else:
|
|
221
|
+
curr_block = cond.get("blocking", 0)
|
|
222
|
+
prev_block = matched.get("blocking", 0)
|
|
223
|
+
if curr_block > prev_block:
|
|
224
|
+
matched = cond
|
|
225
|
+
|
|
226
|
+
return (matched is not None, matched)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Read-only assessment store backed by core API.
|
|
2
|
+
|
|
3
|
+
Used by the deploy gate CLI to read historical assessments from core.
|
|
4
|
+
Synchronous wrapper — safe for CLI use only (no running event loop).
|
|
5
|
+
Gate is CLI-only in v1.5; if gate moves to async (v2), use the
|
|
6
|
+
CoreAPIClient directly instead of this adapter.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
|
|
13
|
+
from nthlayer_common.api_client import CoreAPIClient
|
|
14
|
+
|
|
15
|
+
from nthlayer_workers.observe.assessment import Assessment, from_dict
|
|
16
|
+
from nthlayer_workers.observe.store import AssessmentFilter, AssessmentStore
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CoreAPIAssessmentStore(AssessmentStore):
|
|
20
|
+
"""Read-only assessment store backed by core API."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, client: CoreAPIClient):
|
|
23
|
+
self._client = client
|
|
24
|
+
|
|
25
|
+
def put(self, assessment: Assessment) -> None:
|
|
26
|
+
raise NotImplementedError("CoreAPIAssessmentStore is read-only")
|
|
27
|
+
|
|
28
|
+
def get(self, assessment_id: str) -> Assessment | None:
|
|
29
|
+
# Not needed by gate; single-assessment lookup not exposed in v1.5.
|
|
30
|
+
raise NotImplementedError("Single assessment lookup not needed by gate")
|
|
31
|
+
|
|
32
|
+
def query(self, criteria: AssessmentFilter) -> list[Assessment]:
|
|
33
|
+
result = asyncio.run(self._client.get_assessments(
|
|
34
|
+
service=criteria.service,
|
|
35
|
+
kind=criteria.kind,
|
|
36
|
+
limit=criteria.limit if criteria.limit > 0 else 100,
|
|
37
|
+
))
|
|
38
|
+
if not result.ok:
|
|
39
|
+
return []
|
|
40
|
+
return [from_dict(d) for d in result.data]
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Incident envelope creation for the decision records system.
|
|
2
|
+
|
|
3
|
+
Observe creates incident envelopes when a breach assessment triggers a respond cycle.
|
|
4
|
+
The incident_id is assigned at trigger time and propagated to all downstream records.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import uuid
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
|
|
12
|
+
from nthlayer_common.records.models import Incident
|
|
13
|
+
from nthlayer_common.records.sqlite_store import SQLiteDecisionRecordStore
|
|
14
|
+
|
|
15
|
+
__all__ = ["create_incident_from_breach"]
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def create_incident_from_breach(
|
|
19
|
+
store: SQLiteDecisionRecordStore,
|
|
20
|
+
trigger_hash: str,
|
|
21
|
+
stream: str,
|
|
22
|
+
) -> Incident:
|
|
23
|
+
"""Create an incident envelope from a breach assessment.
|
|
24
|
+
|
|
25
|
+
The incident is written to the store and returned. The caller should use
|
|
26
|
+
the ``incident.id`` to stamp downstream records (verdicts, evaluations).
|
|
27
|
+
"""
|
|
28
|
+
incident_id = f"inc-{uuid.uuid4().hex[:12]}"
|
|
29
|
+
incident = Incident(
|
|
30
|
+
id=incident_id,
|
|
31
|
+
created_at=datetime.now(timezone.utc),
|
|
32
|
+
trigger_hash=trigger_hash,
|
|
33
|
+
stream=stream,
|
|
34
|
+
)
|
|
35
|
+
store.create_incident(incident)
|
|
36
|
+
return incident
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Portfolio health aggregation."""
|
|
2
|
+
|
|
3
|
+
from nthlayer_workers.observe.portfolio.aggregator import (
|
|
4
|
+
PortfolioSummary,
|
|
5
|
+
SLOHealth,
|
|
6
|
+
ServiceHealth,
|
|
7
|
+
build_portfolio,
|
|
8
|
+
)
|
|
9
|
+
from nthlayer_workers.observe.portfolio.scorer import score_service
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"PortfolioSummary",
|
|
13
|
+
"SLOHealth",
|
|
14
|
+
"ServiceHealth",
|
|
15
|
+
"build_portfolio",
|
|
16
|
+
"score_service",
|
|
17
|
+
]
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Portfolio aggregation from assessment store or in-memory SLO results."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
|
|
8
|
+
from nthlayer_workers.observe.slo.collector import SLOResult
|
|
9
|
+
from nthlayer_workers.observe.store import AssessmentFilter, AssessmentStore
|
|
10
|
+
|
|
11
|
+
# Status severity order for worst-status calculation
|
|
12
|
+
_STATUS_SEVERITY = {
|
|
13
|
+
"EXHAUSTED": 4,
|
|
14
|
+
"CRITICAL": 3,
|
|
15
|
+
"WARNING": 2,
|
|
16
|
+
"ERROR": 1,
|
|
17
|
+
"NO_DATA": 0,
|
|
18
|
+
"HEALTHY": -1,
|
|
19
|
+
"UNKNOWN": -2,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class SLOHealth:
|
|
25
|
+
"""Health of a single SLO from assessment data."""
|
|
26
|
+
|
|
27
|
+
name: str
|
|
28
|
+
status: str
|
|
29
|
+
current_sli: float | None = None
|
|
30
|
+
objective: float | None = None
|
|
31
|
+
percent_consumed: float | None = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class ServiceHealth:
|
|
36
|
+
"""Aggregated health for a service across all its SLOs."""
|
|
37
|
+
|
|
38
|
+
service: str
|
|
39
|
+
slos: list[SLOHealth] = field(default_factory=list)
|
|
40
|
+
overall_status: str = "UNKNOWN"
|
|
41
|
+
|
|
42
|
+
def __post_init__(self) -> None:
|
|
43
|
+
if self.slos and self.overall_status == "UNKNOWN":
|
|
44
|
+
self.overall_status = _worst_status([s.status for s in self.slos])
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class PortfolioSummary:
|
|
49
|
+
"""Org-level portfolio health aggregated from assessments."""
|
|
50
|
+
|
|
51
|
+
services: list[ServiceHealth] = field(default_factory=list)
|
|
52
|
+
total_services: int = 0
|
|
53
|
+
healthy_count: int = 0
|
|
54
|
+
warning_count: int = 0
|
|
55
|
+
critical_count: int = 0
|
|
56
|
+
exhausted_count: int = 0
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def build_portfolio(store: AssessmentStore) -> PortfolioSummary:
|
|
60
|
+
"""Build portfolio summary from recent slo_status assessments.
|
|
61
|
+
|
|
62
|
+
Reads the most recent slo_status assessment per service+SLO combination,
|
|
63
|
+
groups by service, and aggregates health status.
|
|
64
|
+
"""
|
|
65
|
+
assessments = store.query(
|
|
66
|
+
AssessmentFilter(kind="slo_status", limit=0)
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
# Group by service, keeping only the latest assessment per service+slo_name
|
|
70
|
+
latest: dict[str, dict[str, dict]] = defaultdict(dict)
|
|
71
|
+
for a in assessments:
|
|
72
|
+
slo_name = a.data.get("slo_name", "unknown")
|
|
73
|
+
key = slo_name
|
|
74
|
+
# Assessments are ordered by timestamp desc, so first seen is latest
|
|
75
|
+
if key not in latest[a.service]:
|
|
76
|
+
latest[a.service][key] = a.data
|
|
77
|
+
|
|
78
|
+
services = []
|
|
79
|
+
for service_name in sorted(latest):
|
|
80
|
+
slos = []
|
|
81
|
+
for slo_name, data in sorted(latest[service_name].items()):
|
|
82
|
+
slos.append(
|
|
83
|
+
SLOHealth(
|
|
84
|
+
name=slo_name,
|
|
85
|
+
status=data.get("status", "UNKNOWN"),
|
|
86
|
+
current_sli=data.get("current_sli"),
|
|
87
|
+
objective=data.get("objective"),
|
|
88
|
+
percent_consumed=data.get("percent_consumed"),
|
|
89
|
+
)
|
|
90
|
+
)
|
|
91
|
+
services.append(ServiceHealth(service=service_name, slos=slos))
|
|
92
|
+
|
|
93
|
+
return _summarise_services(services)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def build_portfolio_from_results(
|
|
97
|
+
results_by_service: dict[str, list[SLOResult]],
|
|
98
|
+
) -> PortfolioSummary:
|
|
99
|
+
"""Build portfolio summary from current-cycle SLO results.
|
|
100
|
+
|
|
101
|
+
Used by ObserveCollectModule.process_cycle() — receives results
|
|
102
|
+
in-memory from the collector, no store round-trip. Parallel to
|
|
103
|
+
build_portfolio(store) which reads from an AssessmentStore.
|
|
104
|
+
"""
|
|
105
|
+
services = []
|
|
106
|
+
for service_name in sorted(results_by_service):
|
|
107
|
+
slos = []
|
|
108
|
+
for r in results_by_service[service_name]:
|
|
109
|
+
slos.append(
|
|
110
|
+
SLOHealth(
|
|
111
|
+
name=r.name,
|
|
112
|
+
status=r.status,
|
|
113
|
+
current_sli=r.current_sli,
|
|
114
|
+
objective=r.objective,
|
|
115
|
+
percent_consumed=r.percent_consumed,
|
|
116
|
+
)
|
|
117
|
+
)
|
|
118
|
+
services.append(ServiceHealth(service=service_name, slos=slos))
|
|
119
|
+
|
|
120
|
+
return _summarise_services(services)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _summarise_services(services: list[ServiceHealth]) -> PortfolioSummary:
|
|
124
|
+
"""Build PortfolioSummary from a list of ServiceHealth objects.
|
|
125
|
+
|
|
126
|
+
Shared by build_portfolio (store path) and build_portfolio_from_results
|
|
127
|
+
(in-memory path). Must stay in sync — single implementation.
|
|
128
|
+
"""
|
|
129
|
+
status_counts: dict[str, int] = defaultdict(int)
|
|
130
|
+
for svc in services:
|
|
131
|
+
bucket = _status_bucket(svc.overall_status)
|
|
132
|
+
status_counts[bucket] += 1
|
|
133
|
+
|
|
134
|
+
return PortfolioSummary(
|
|
135
|
+
services=services,
|
|
136
|
+
total_services=len(services),
|
|
137
|
+
healthy_count=status_counts["healthy"],
|
|
138
|
+
warning_count=status_counts["warning"],
|
|
139
|
+
critical_count=status_counts["critical"],
|
|
140
|
+
exhausted_count=status_counts["exhausted"],
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _worst_status(statuses: list[str]) -> str:
|
|
145
|
+
"""Return the worst status from a list."""
|
|
146
|
+
if not statuses:
|
|
147
|
+
return "UNKNOWN"
|
|
148
|
+
return max(statuses, key=lambda s: _STATUS_SEVERITY.get(s, -2))
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _status_bucket(status: str) -> str:
|
|
152
|
+
"""Map a status to a summary bucket.
|
|
153
|
+
|
|
154
|
+
UNKNOWN/NO_DATA/ERROR map to "unknown" which is NOT tracked in
|
|
155
|
+
PortfolioSummary (no unknown_count field). These represent missing
|
|
156
|
+
information, not active failures — they are silently excluded from
|
|
157
|
+
the healthy/warning/critical/exhausted counts.
|
|
158
|
+
"""
|
|
159
|
+
buckets = {
|
|
160
|
+
"HEALTHY": "healthy",
|
|
161
|
+
"WARNING": "warning",
|
|
162
|
+
"CRITICAL": "critical",
|
|
163
|
+
"EXHAUSTED": "exhausted",
|
|
164
|
+
"UNKNOWN": "unknown",
|
|
165
|
+
"NO_DATA": "unknown",
|
|
166
|
+
"ERROR": "unknown",
|
|
167
|
+
}
|
|
168
|
+
return buckets.get(status, "unknown")
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Simple reliability scoring from portfolio data."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from nthlayer_workers.observe.portfolio.aggregator import ServiceHealth
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def score_service(health: ServiceHealth) -> float:
|
|
9
|
+
"""Score a service based on its SLO compliance (0-100)."""
|
|
10
|
+
if not health.slos:
|
|
11
|
+
return 0.0
|
|
12
|
+
healthy = sum(1 for s in health.slos if s.status == "HEALTHY")
|
|
13
|
+
return (healthy / len(health.slos)) * 100
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""SLO state collection and storage."""
|
|
2
|
+
|
|
3
|
+
from nthlayer_workers.observe.slo.collector import (
|
|
4
|
+
BudgetSummary,
|
|
5
|
+
SLOMetricCollector,
|
|
6
|
+
SLOResult,
|
|
7
|
+
ServiceSLO,
|
|
8
|
+
results_to_assessments,
|
|
9
|
+
)
|
|
10
|
+
from nthlayer_workers.observe.slo.spec_loader import load_specs
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"BudgetSummary",
|
|
14
|
+
"SLOMetricCollector",
|
|
15
|
+
"SLOResult",
|
|
16
|
+
"ServiceSLO",
|
|
17
|
+
"load_specs",
|
|
18
|
+
"results_to_assessments",
|
|
19
|
+
]
|