kstlib 0.0.1a0__py3-none-any.whl → 1.0.1__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.
- kstlib/__init__.py +266 -1
- kstlib/__main__.py +16 -0
- kstlib/alerts/__init__.py +110 -0
- kstlib/alerts/channels/__init__.py +36 -0
- kstlib/alerts/channels/base.py +197 -0
- kstlib/alerts/channels/email.py +227 -0
- kstlib/alerts/channels/slack.py +389 -0
- kstlib/alerts/exceptions.py +72 -0
- kstlib/alerts/manager.py +651 -0
- kstlib/alerts/models.py +142 -0
- kstlib/alerts/throttle.py +263 -0
- kstlib/auth/__init__.py +139 -0
- kstlib/auth/callback.py +399 -0
- kstlib/auth/config.py +502 -0
- kstlib/auth/errors.py +127 -0
- kstlib/auth/models.py +316 -0
- kstlib/auth/providers/__init__.py +14 -0
- kstlib/auth/providers/base.py +393 -0
- kstlib/auth/providers/oauth2.py +645 -0
- kstlib/auth/providers/oidc.py +821 -0
- kstlib/auth/session.py +338 -0
- kstlib/auth/token.py +482 -0
- kstlib/cache/__init__.py +50 -0
- kstlib/cache/decorator.py +261 -0
- kstlib/cache/strategies.py +516 -0
- kstlib/cli/__init__.py +8 -0
- kstlib/cli/app.py +195 -0
- kstlib/cli/commands/__init__.py +5 -0
- kstlib/cli/commands/auth/__init__.py +39 -0
- kstlib/cli/commands/auth/common.py +122 -0
- kstlib/cli/commands/auth/login.py +325 -0
- kstlib/cli/commands/auth/logout.py +74 -0
- kstlib/cli/commands/auth/providers.py +57 -0
- kstlib/cli/commands/auth/status.py +291 -0
- kstlib/cli/commands/auth/token.py +199 -0
- kstlib/cli/commands/auth/whoami.py +106 -0
- kstlib/cli/commands/config.py +89 -0
- kstlib/cli/commands/ops/__init__.py +39 -0
- kstlib/cli/commands/ops/attach.py +49 -0
- kstlib/cli/commands/ops/common.py +269 -0
- kstlib/cli/commands/ops/list_sessions.py +252 -0
- kstlib/cli/commands/ops/logs.py +49 -0
- kstlib/cli/commands/ops/start.py +98 -0
- kstlib/cli/commands/ops/status.py +138 -0
- kstlib/cli/commands/ops/stop.py +60 -0
- kstlib/cli/commands/rapi/__init__.py +60 -0
- kstlib/cli/commands/rapi/call.py +341 -0
- kstlib/cli/commands/rapi/list.py +99 -0
- kstlib/cli/commands/rapi/show.py +206 -0
- kstlib/cli/commands/secrets/__init__.py +35 -0
- kstlib/cli/commands/secrets/common.py +425 -0
- kstlib/cli/commands/secrets/decrypt.py +88 -0
- kstlib/cli/commands/secrets/doctor.py +743 -0
- kstlib/cli/commands/secrets/encrypt.py +242 -0
- kstlib/cli/commands/secrets/shred.py +96 -0
- kstlib/cli/common.py +86 -0
- kstlib/config/__init__.py +76 -0
- kstlib/config/exceptions.py +110 -0
- kstlib/config/export.py +225 -0
- kstlib/config/loader.py +963 -0
- kstlib/config/sops.py +287 -0
- kstlib/db/__init__.py +54 -0
- kstlib/db/aiosqlcipher.py +137 -0
- kstlib/db/cipher.py +112 -0
- kstlib/db/database.py +367 -0
- kstlib/db/exceptions.py +25 -0
- kstlib/db/pool.py +302 -0
- kstlib/helpers/__init__.py +35 -0
- kstlib/helpers/exceptions.py +11 -0
- kstlib/helpers/time_trigger.py +396 -0
- kstlib/kstlib.conf.yml +890 -0
- kstlib/limits.py +963 -0
- kstlib/logging/__init__.py +108 -0
- kstlib/logging/manager.py +633 -0
- kstlib/mail/__init__.py +42 -0
- kstlib/mail/builder.py +626 -0
- kstlib/mail/exceptions.py +27 -0
- kstlib/mail/filesystem.py +248 -0
- kstlib/mail/transport.py +224 -0
- kstlib/mail/transports/__init__.py +19 -0
- kstlib/mail/transports/gmail.py +268 -0
- kstlib/mail/transports/resend.py +324 -0
- kstlib/mail/transports/smtp.py +326 -0
- kstlib/meta.py +72 -0
- kstlib/metrics/__init__.py +88 -0
- kstlib/metrics/decorators.py +1090 -0
- kstlib/metrics/exceptions.py +14 -0
- kstlib/monitoring/__init__.py +116 -0
- kstlib/monitoring/_styles.py +163 -0
- kstlib/monitoring/cell.py +57 -0
- kstlib/monitoring/config.py +424 -0
- kstlib/monitoring/delivery.py +579 -0
- kstlib/monitoring/exceptions.py +63 -0
- kstlib/monitoring/image.py +220 -0
- kstlib/monitoring/kv.py +79 -0
- kstlib/monitoring/list.py +69 -0
- kstlib/monitoring/metric.py +88 -0
- kstlib/monitoring/monitoring.py +341 -0
- kstlib/monitoring/renderer.py +139 -0
- kstlib/monitoring/service.py +392 -0
- kstlib/monitoring/table.py +129 -0
- kstlib/monitoring/types.py +56 -0
- kstlib/ops/__init__.py +86 -0
- kstlib/ops/base.py +148 -0
- kstlib/ops/container.py +577 -0
- kstlib/ops/exceptions.py +209 -0
- kstlib/ops/manager.py +407 -0
- kstlib/ops/models.py +176 -0
- kstlib/ops/tmux.py +372 -0
- kstlib/ops/validators.py +287 -0
- kstlib/py.typed +0 -0
- kstlib/rapi/__init__.py +118 -0
- kstlib/rapi/client.py +875 -0
- kstlib/rapi/config.py +861 -0
- kstlib/rapi/credentials.py +887 -0
- kstlib/rapi/exceptions.py +213 -0
- kstlib/resilience/__init__.py +101 -0
- kstlib/resilience/circuit_breaker.py +440 -0
- kstlib/resilience/exceptions.py +95 -0
- kstlib/resilience/heartbeat.py +491 -0
- kstlib/resilience/rate_limiter.py +506 -0
- kstlib/resilience/shutdown.py +417 -0
- kstlib/resilience/watchdog.py +637 -0
- kstlib/secrets/__init__.py +29 -0
- kstlib/secrets/exceptions.py +19 -0
- kstlib/secrets/models.py +62 -0
- kstlib/secrets/providers/__init__.py +79 -0
- kstlib/secrets/providers/base.py +58 -0
- kstlib/secrets/providers/environment.py +66 -0
- kstlib/secrets/providers/keyring.py +107 -0
- kstlib/secrets/providers/kms.py +223 -0
- kstlib/secrets/providers/kwargs.py +101 -0
- kstlib/secrets/providers/sops.py +209 -0
- kstlib/secrets/resolver.py +221 -0
- kstlib/secrets/sensitive.py +130 -0
- kstlib/secure/__init__.py +23 -0
- kstlib/secure/fs.py +194 -0
- kstlib/secure/permissions.py +70 -0
- kstlib/ssl.py +347 -0
- kstlib/ui/__init__.py +23 -0
- kstlib/ui/exceptions.py +26 -0
- kstlib/ui/panels.py +484 -0
- kstlib/ui/spinner.py +864 -0
- kstlib/ui/tables.py +382 -0
- kstlib/utils/__init__.py +48 -0
- kstlib/utils/dict.py +36 -0
- kstlib/utils/formatting.py +338 -0
- kstlib/utils/http_trace.py +237 -0
- kstlib/utils/lazy.py +49 -0
- kstlib/utils/secure_delete.py +205 -0
- kstlib/utils/serialization.py +247 -0
- kstlib/utils/text.py +56 -0
- kstlib/utils/validators.py +124 -0
- kstlib/websocket/__init__.py +97 -0
- kstlib/websocket/exceptions.py +214 -0
- kstlib/websocket/manager.py +1102 -0
- kstlib/websocket/models.py +361 -0
- kstlib-1.0.1.dist-info/METADATA +201 -0
- kstlib-1.0.1.dist-info/RECORD +163 -0
- {kstlib-0.0.1a0.dist-info → kstlib-1.0.1.dist-info}/WHEEL +1 -1
- kstlib-1.0.1.dist-info/entry_points.txt +2 -0
- kstlib-1.0.1.dist-info/licenses/LICENSE.md +9 -0
- kstlib-0.0.1a0.dist-info/METADATA +0 -29
- kstlib-0.0.1a0.dist-info/RECORD +0 -6
- kstlib-0.0.1a0.dist-info/licenses/LICENSE.md +0 -5
- {kstlib-0.0.1a0.dist-info → kstlib-1.0.1.dist-info}/top_level.txt +0 -0
kstlib/alerts/manager.py
ADDED
|
@@ -0,0 +1,651 @@
|
|
|
1
|
+
"""Alert manager for orchestrating multi-channel delivery.
|
|
2
|
+
|
|
3
|
+
The AlertManager coordinates sending alerts to multiple channels with
|
|
4
|
+
per-channel level filtering and optional throttling.
|
|
5
|
+
|
|
6
|
+
Examples:
|
|
7
|
+
Basic setup::
|
|
8
|
+
|
|
9
|
+
from kstlib.alerts import AlertManager, AlertLevel
|
|
10
|
+
from kstlib.alerts.channels import SlackChannel, EmailChannel
|
|
11
|
+
|
|
12
|
+
manager = AlertManager()
|
|
13
|
+
manager.add_channel(slack_channel, min_level=AlertLevel.WARNING)
|
|
14
|
+
manager.add_channel(email_channel, min_level=AlertLevel.CRITICAL)
|
|
15
|
+
|
|
16
|
+
results = await manager.send(alert)
|
|
17
|
+
|
|
18
|
+
With throttling::
|
|
19
|
+
|
|
20
|
+
from kstlib.alerts.throttle import AlertThrottle
|
|
21
|
+
|
|
22
|
+
throttle = AlertThrottle(rate=10, per=60.0)
|
|
23
|
+
manager.add_channel(slack_channel, throttle=throttle)
|
|
24
|
+
|
|
25
|
+
Fluent API::
|
|
26
|
+
|
|
27
|
+
manager = (
|
|
28
|
+
AlertManager()
|
|
29
|
+
.add_channel(slack_channel, min_level=AlertLevel.INFO)
|
|
30
|
+
.add_channel(email_channel, min_level=AlertLevel.CRITICAL)
|
|
31
|
+
)
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import asyncio
|
|
37
|
+
import logging
|
|
38
|
+
from dataclasses import dataclass, field
|
|
39
|
+
from typing import TYPE_CHECKING, Any
|
|
40
|
+
|
|
41
|
+
from kstlib.alerts.channels.base import AlertChannel, AsyncAlertChannel, AsyncChannelWrapper
|
|
42
|
+
from kstlib.alerts.exceptions import AlertConfigurationError, AlertThrottledError
|
|
43
|
+
from kstlib.alerts.models import AlertLevel, AlertResult
|
|
44
|
+
|
|
45
|
+
if TYPE_CHECKING:
|
|
46
|
+
from collections.abc import Mapping
|
|
47
|
+
|
|
48
|
+
from typing_extensions import Self
|
|
49
|
+
|
|
50
|
+
from kstlib.alerts.models import AlertMessage
|
|
51
|
+
from kstlib.alerts.throttle import AlertThrottle
|
|
52
|
+
from kstlib.mail.transport import AsyncMailTransport, MailTransport
|
|
53
|
+
from kstlib.rapi.credentials import CredentialResolver
|
|
54
|
+
|
|
55
|
+
__all__ = ["AlertManager"]
|
|
56
|
+
|
|
57
|
+
log = logging.getLogger(__name__)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class _ChannelEntry:
|
|
62
|
+
"""Internal entry for a registered channel."""
|
|
63
|
+
|
|
64
|
+
channel: AsyncAlertChannel
|
|
65
|
+
min_level: AlertLevel = AlertLevel.INFO
|
|
66
|
+
throttle: AlertThrottle | None = None
|
|
67
|
+
key: str | None = None # Config key (e.g., "hb")
|
|
68
|
+
alias: str | None = None # Optional alias (e.g., "heartbeat")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass
|
|
72
|
+
class AlertManagerStats:
|
|
73
|
+
"""Statistics for alert manager monitoring.
|
|
74
|
+
|
|
75
|
+
Attributes:
|
|
76
|
+
total_sent: Total alerts successfully sent.
|
|
77
|
+
total_failed: Total alerts that failed delivery.
|
|
78
|
+
total_throttled: Total alerts dropped due to throttling.
|
|
79
|
+
by_channel: Per-channel statistics.
|
|
80
|
+
"""
|
|
81
|
+
|
|
82
|
+
total_sent: int = 0
|
|
83
|
+
total_failed: int = 0
|
|
84
|
+
total_throttled: int = 0
|
|
85
|
+
by_channel: dict[str, dict[str, int]] = field(default_factory=dict)
|
|
86
|
+
|
|
87
|
+
def record_sent(self, channel: str) -> None:
|
|
88
|
+
"""Record a successful send."""
|
|
89
|
+
self.total_sent += 1
|
|
90
|
+
self._ensure_channel(channel)
|
|
91
|
+
self.by_channel[channel]["sent"] += 1
|
|
92
|
+
|
|
93
|
+
def record_failed(self, channel: str) -> None:
|
|
94
|
+
"""Record a failed send."""
|
|
95
|
+
self.total_failed += 1
|
|
96
|
+
self._ensure_channel(channel)
|
|
97
|
+
self.by_channel[channel]["failed"] += 1
|
|
98
|
+
|
|
99
|
+
def record_throttled(self, channel: str) -> None:
|
|
100
|
+
"""Record a throttled alert."""
|
|
101
|
+
self.total_throttled += 1
|
|
102
|
+
self._ensure_channel(channel)
|
|
103
|
+
self.by_channel[channel]["throttled"] += 1
|
|
104
|
+
|
|
105
|
+
def _ensure_channel(self, channel: str) -> None:
|
|
106
|
+
"""Ensure channel stats dict exists."""
|
|
107
|
+
if channel not in self.by_channel:
|
|
108
|
+
self.by_channel[channel] = {"sent": 0, "failed": 0, "throttled": 0}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class AlertManager:
|
|
112
|
+
"""Orchestrates alert delivery to multiple channels.
|
|
113
|
+
|
|
114
|
+
Manages a collection of alert channels with per-channel level
|
|
115
|
+
filtering and optional throttling. Alerts are sent concurrently
|
|
116
|
+
to all matching channels.
|
|
117
|
+
|
|
118
|
+
Examples:
|
|
119
|
+
Basic usage::
|
|
120
|
+
|
|
121
|
+
manager = AlertManager()
|
|
122
|
+
manager.add_channel(slack_channel)
|
|
123
|
+
manager.add_channel(email_channel, min_level=AlertLevel.CRITICAL)
|
|
124
|
+
|
|
125
|
+
alert = AlertMessage(
|
|
126
|
+
title="Service Down",
|
|
127
|
+
body="API server not responding",
|
|
128
|
+
level=AlertLevel.CRITICAL,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
results = await manager.send(alert)
|
|
132
|
+
for result in results:
|
|
133
|
+
if result.success:
|
|
134
|
+
print(f"{result.channel}: OK")
|
|
135
|
+
|
|
136
|
+
Fluent API::
|
|
137
|
+
|
|
138
|
+
manager = (
|
|
139
|
+
AlertManager()
|
|
140
|
+
.add_channel(slack, min_level=AlertLevel.WARNING)
|
|
141
|
+
.add_channel(email, min_level=AlertLevel.CRITICAL)
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
From config::
|
|
145
|
+
|
|
146
|
+
manager = AlertManager.from_config(
|
|
147
|
+
config=config["alerts"],
|
|
148
|
+
credential_resolver=resolver,
|
|
149
|
+
)
|
|
150
|
+
"""
|
|
151
|
+
|
|
152
|
+
def __init__(self) -> None:
|
|
153
|
+
"""Initialize AlertManager with no channels."""
|
|
154
|
+
self._channels: list[_ChannelEntry] = []
|
|
155
|
+
self._stats = AlertManagerStats()
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def stats(self) -> AlertManagerStats:
|
|
159
|
+
"""Return statistics for this manager."""
|
|
160
|
+
return self._stats
|
|
161
|
+
|
|
162
|
+
@property
|
|
163
|
+
def channel_count(self) -> int:
|
|
164
|
+
"""Return number of registered channels."""
|
|
165
|
+
return len(self._channels)
|
|
166
|
+
|
|
167
|
+
def add_channel(
|
|
168
|
+
self,
|
|
169
|
+
channel: AlertChannel | AsyncAlertChannel,
|
|
170
|
+
*,
|
|
171
|
+
min_level: AlertLevel = AlertLevel.INFO,
|
|
172
|
+
throttle: AlertThrottle | None = None,
|
|
173
|
+
key: str | None = None,
|
|
174
|
+
alias: str | None = None,
|
|
175
|
+
) -> Self:
|
|
176
|
+
"""Add a channel to the manager.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
channel: The channel to add (sync or async).
|
|
180
|
+
min_level: Minimum alert level for this channel.
|
|
181
|
+
throttle: Optional throttle for rate limiting.
|
|
182
|
+
key: Config key for targeting (e.g., "hb").
|
|
183
|
+
alias: Human-readable alias for targeting (e.g., "heartbeat").
|
|
184
|
+
|
|
185
|
+
Returns:
|
|
186
|
+
Self for fluent chaining.
|
|
187
|
+
|
|
188
|
+
Examples:
|
|
189
|
+
>>> manager = AlertManager()
|
|
190
|
+
>>> manager.add_channel(slack_channel) # doctest: +SKIP
|
|
191
|
+
AlertManager(channels=1)
|
|
192
|
+
>>> manager.add_channel(email_channel, min_level=AlertLevel.CRITICAL) # doctest: +SKIP
|
|
193
|
+
AlertManager(channels=2)
|
|
194
|
+
"""
|
|
195
|
+
# Wrap sync channels for async usage
|
|
196
|
+
if isinstance(channel, AlertChannel):
|
|
197
|
+
async_channel: AsyncAlertChannel = AsyncChannelWrapper(channel)
|
|
198
|
+
else:
|
|
199
|
+
async_channel = channel
|
|
200
|
+
|
|
201
|
+
entry = _ChannelEntry(
|
|
202
|
+
channel=async_channel,
|
|
203
|
+
min_level=min_level,
|
|
204
|
+
throttle=throttle,
|
|
205
|
+
key=key,
|
|
206
|
+
alias=alias,
|
|
207
|
+
)
|
|
208
|
+
self._channels.append(entry)
|
|
209
|
+
|
|
210
|
+
log.debug(
|
|
211
|
+
"Added channel: name=%s, min_level=%s, throttle=%s",
|
|
212
|
+
async_channel.name,
|
|
213
|
+
min_level.name,
|
|
214
|
+
throttle is not None,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
return self
|
|
218
|
+
|
|
219
|
+
async def send(
|
|
220
|
+
self,
|
|
221
|
+
alert: AlertMessage | list[AlertMessage],
|
|
222
|
+
*,
|
|
223
|
+
channel: str | None = None,
|
|
224
|
+
) -> list[AlertResult]:
|
|
225
|
+
"""Send one or more alerts to matching channels.
|
|
226
|
+
|
|
227
|
+
Delivers alerts concurrently to channels where the alert level
|
|
228
|
+
meets the channel's minimum level. Optionally target a specific
|
|
229
|
+
channel by key or alias.
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
alert: Single alert or list of alerts to send.
|
|
233
|
+
channel: Optional channel key or alias to target. If None,
|
|
234
|
+
broadcasts to all matching channels based on level.
|
|
235
|
+
|
|
236
|
+
Returns:
|
|
237
|
+
Flat list of AlertResult for all alerts and channels.
|
|
238
|
+
|
|
239
|
+
Examples:
|
|
240
|
+
Send single alert (broadcast)::
|
|
241
|
+
|
|
242
|
+
>>> results = await manager.send(alert) # doctest: +SKIP
|
|
243
|
+
|
|
244
|
+
Send single alert to specific channel::
|
|
245
|
+
|
|
246
|
+
>>> results = await manager.send(alert, channel="hb") # doctest: +SKIP
|
|
247
|
+
|
|
248
|
+
Send multiple alerts to same channel::
|
|
249
|
+
|
|
250
|
+
>>> alerts = [alert1, alert2, alert3] # doctest: +SKIP
|
|
251
|
+
>>> results = await manager.send(alerts, channel="watchdog") # doctest: +SKIP
|
|
252
|
+
"""
|
|
253
|
+
if not self._channels:
|
|
254
|
+
log.warning("No channels configured, alert not sent")
|
|
255
|
+
return []
|
|
256
|
+
|
|
257
|
+
# Normalize to list
|
|
258
|
+
alerts = [alert] if not isinstance(alert, list) else alert
|
|
259
|
+
|
|
260
|
+
if not alerts:
|
|
261
|
+
return []
|
|
262
|
+
|
|
263
|
+
# Get target entries (if channel specified)
|
|
264
|
+
target_entries: list[_ChannelEntry] | None = None
|
|
265
|
+
if channel is not None:
|
|
266
|
+
target_entries = self._find_channel(channel)
|
|
267
|
+
if not target_entries:
|
|
268
|
+
log.warning("Channel '%s' not found", channel)
|
|
269
|
+
return []
|
|
270
|
+
|
|
271
|
+
# Send all alerts
|
|
272
|
+
all_results: list[AlertResult] = []
|
|
273
|
+
for single_alert in alerts:
|
|
274
|
+
results = await self._send_alert(single_alert, target_entries)
|
|
275
|
+
all_results.extend(results)
|
|
276
|
+
|
|
277
|
+
return all_results
|
|
278
|
+
|
|
279
|
+
async def _send_alert(
|
|
280
|
+
self,
|
|
281
|
+
alert: AlertMessage,
|
|
282
|
+
target_entries: list[_ChannelEntry] | None,
|
|
283
|
+
) -> list[AlertResult]:
|
|
284
|
+
"""Send a single alert to matching channels.
|
|
285
|
+
|
|
286
|
+
Args:
|
|
287
|
+
alert: The alert to send.
|
|
288
|
+
target_entries: Specific entries to target, or None for broadcast.
|
|
289
|
+
|
|
290
|
+
Returns:
|
|
291
|
+
List of results for this alert.
|
|
292
|
+
"""
|
|
293
|
+
# Determine matching entries
|
|
294
|
+
if target_entries is not None:
|
|
295
|
+
matching_entries = target_entries
|
|
296
|
+
else:
|
|
297
|
+
# Filter channels by level (broadcast mode)
|
|
298
|
+
matching_entries = [entry for entry in self._channels if alert.level >= entry.min_level]
|
|
299
|
+
|
|
300
|
+
if not matching_entries:
|
|
301
|
+
log.debug(
|
|
302
|
+
"No channels match alert level %s",
|
|
303
|
+
alert.level.name,
|
|
304
|
+
)
|
|
305
|
+
return []
|
|
306
|
+
|
|
307
|
+
log.debug(
|
|
308
|
+
"Sending alert to %d channels: level=%s, title=%r",
|
|
309
|
+
len(matching_entries),
|
|
310
|
+
alert.level.name,
|
|
311
|
+
alert.title[:50],
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
# Send to all matching channels concurrently
|
|
315
|
+
tasks = [self._send_to_entry(entry, alert) for entry in matching_entries]
|
|
316
|
+
results = await asyncio.gather(*tasks, return_exceptions=False)
|
|
317
|
+
|
|
318
|
+
return list(results)
|
|
319
|
+
|
|
320
|
+
def _find_channel(self, identifier: str) -> list[_ChannelEntry]:
|
|
321
|
+
"""Find channel entry by key, alias, or channel name.
|
|
322
|
+
|
|
323
|
+
Args:
|
|
324
|
+
identifier: Channel key, alias, or name.
|
|
325
|
+
|
|
326
|
+
Returns:
|
|
327
|
+
List with matching entry, or empty list if not found.
|
|
328
|
+
"""
|
|
329
|
+
for entry in self._channels:
|
|
330
|
+
# Match by key (e.g., "hb")
|
|
331
|
+
if entry.key and entry.key == identifier:
|
|
332
|
+
return [entry]
|
|
333
|
+
# Match by alias (e.g., "heartbeat")
|
|
334
|
+
if entry.alias and entry.alias == identifier:
|
|
335
|
+
return [entry]
|
|
336
|
+
# Match by channel name (fallback)
|
|
337
|
+
if entry.channel.name == identifier:
|
|
338
|
+
return [entry]
|
|
339
|
+
return []
|
|
340
|
+
|
|
341
|
+
async def _send_to_entry(
|
|
342
|
+
self,
|
|
343
|
+
entry: _ChannelEntry,
|
|
344
|
+
alert: AlertMessage,
|
|
345
|
+
) -> AlertResult:
|
|
346
|
+
"""Send alert to a single channel entry.
|
|
347
|
+
|
|
348
|
+
Args:
|
|
349
|
+
entry: The channel entry with config.
|
|
350
|
+
alert: The alert to send.
|
|
351
|
+
|
|
352
|
+
Returns:
|
|
353
|
+
AlertResult with delivery status.
|
|
354
|
+
"""
|
|
355
|
+
channel_name = entry.channel.name
|
|
356
|
+
|
|
357
|
+
# Check throttle if configured
|
|
358
|
+
if entry.throttle is not None and not entry.throttle.try_acquire():
|
|
359
|
+
self._stats.record_throttled(channel_name)
|
|
360
|
+
log.debug("Alert throttled for channel: %s", channel_name)
|
|
361
|
+
return AlertResult(
|
|
362
|
+
channel=channel_name,
|
|
363
|
+
success=False,
|
|
364
|
+
error="Rate limit exceeded",
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
try:
|
|
368
|
+
result = await entry.channel.send(alert)
|
|
369
|
+
if result.success:
|
|
370
|
+
self._stats.record_sent(channel_name)
|
|
371
|
+
else:
|
|
372
|
+
self._stats.record_failed(channel_name)
|
|
373
|
+
return result
|
|
374
|
+
|
|
375
|
+
except AlertThrottledError as e:
|
|
376
|
+
self._stats.record_throttled(channel_name)
|
|
377
|
+
return AlertResult(
|
|
378
|
+
channel=channel_name,
|
|
379
|
+
success=False,
|
|
380
|
+
error=f"Throttled: retry after {e.retry_after}s",
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
except Exception as e:
|
|
384
|
+
self._stats.record_failed(channel_name)
|
|
385
|
+
log.warning(
|
|
386
|
+
"Channel %s failed: %s",
|
|
387
|
+
channel_name,
|
|
388
|
+
e,
|
|
389
|
+
)
|
|
390
|
+
return AlertResult(
|
|
391
|
+
channel=channel_name,
|
|
392
|
+
success=False,
|
|
393
|
+
error=str(e),
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
@classmethod
|
|
397
|
+
def from_config(
|
|
398
|
+
cls,
|
|
399
|
+
config: Mapping[str, Any],
|
|
400
|
+
credential_resolver: CredentialResolver | None = None,
|
|
401
|
+
) -> AlertManager:
|
|
402
|
+
"""Create AlertManager from configuration dict.
|
|
403
|
+
|
|
404
|
+
Config format::
|
|
405
|
+
|
|
406
|
+
alerts:
|
|
407
|
+
throttle:
|
|
408
|
+
rate: 10
|
|
409
|
+
per: 60
|
|
410
|
+
|
|
411
|
+
channels:
|
|
412
|
+
slack_ops:
|
|
413
|
+
type: slack
|
|
414
|
+
credentials: slack_webhook
|
|
415
|
+
username: "kstlib-alerts"
|
|
416
|
+
min_level: warning
|
|
417
|
+
|
|
418
|
+
email_critical:
|
|
419
|
+
type: email
|
|
420
|
+
transport:
|
|
421
|
+
type: smtp
|
|
422
|
+
host: smtp.example.com
|
|
423
|
+
sender: "alerts@example.com"
|
|
424
|
+
recipients: ["oncall@example.com"]
|
|
425
|
+
min_level: critical
|
|
426
|
+
|
|
427
|
+
Args:
|
|
428
|
+
config: Alerts configuration dict.
|
|
429
|
+
credential_resolver: Resolver for credential references.
|
|
430
|
+
|
|
431
|
+
Returns:
|
|
432
|
+
Configured AlertManager instance.
|
|
433
|
+
|
|
434
|
+
Raises:
|
|
435
|
+
AlertConfigurationError: If configuration is invalid.
|
|
436
|
+
"""
|
|
437
|
+
from kstlib.alerts.channels import SlackChannel
|
|
438
|
+
from kstlib.alerts.throttle import AlertThrottle
|
|
439
|
+
|
|
440
|
+
manager = cls()
|
|
441
|
+
|
|
442
|
+
# Parse global throttle config
|
|
443
|
+
global_throttle = None
|
|
444
|
+
throttle_config = config.get("throttle")
|
|
445
|
+
if throttle_config:
|
|
446
|
+
global_throttle = AlertThrottle(
|
|
447
|
+
rate=float(throttle_config.get("rate", 10)),
|
|
448
|
+
per=float(throttle_config.get("per", 60)),
|
|
449
|
+
)
|
|
450
|
+
|
|
451
|
+
# Parse channels
|
|
452
|
+
channels_config = config.get("channels", {})
|
|
453
|
+
if not channels_config:
|
|
454
|
+
log.warning("No channels configured in alerts config")
|
|
455
|
+
return manager
|
|
456
|
+
|
|
457
|
+
for config_key, channel_config in channels_config.items():
|
|
458
|
+
channel_type = channel_config.get("type", "").lower()
|
|
459
|
+
|
|
460
|
+
# Extract alias from config (optional "name" field)
|
|
461
|
+
# If not specified, alias defaults to None (use key for targeting)
|
|
462
|
+
channel_alias = channel_config.get("name")
|
|
463
|
+
|
|
464
|
+
# Parse min_level
|
|
465
|
+
min_level_str = channel_config.get("min_level", "info").lower()
|
|
466
|
+
min_level = _parse_level(min_level_str)
|
|
467
|
+
|
|
468
|
+
# Parse per-channel throttle (or use global)
|
|
469
|
+
channel_throttle = global_throttle
|
|
470
|
+
if "throttle" in channel_config:
|
|
471
|
+
tc = channel_config["throttle"]
|
|
472
|
+
channel_throttle = AlertThrottle(
|
|
473
|
+
rate=float(tc.get("rate", 10)),
|
|
474
|
+
per=float(tc.get("per", 60)),
|
|
475
|
+
)
|
|
476
|
+
|
|
477
|
+
# Determine display name: use alias if provided, else config key
|
|
478
|
+
display_name = channel_alias if channel_alias else config_key
|
|
479
|
+
|
|
480
|
+
# Create channel based on type
|
|
481
|
+
try:
|
|
482
|
+
if channel_type == "slack":
|
|
483
|
+
channel: AlertChannel | AsyncAlertChannel = SlackChannel.from_config(
|
|
484
|
+
{**channel_config, "name": display_name},
|
|
485
|
+
credential_resolver,
|
|
486
|
+
)
|
|
487
|
+
elif channel_type == "email":
|
|
488
|
+
channel = _create_email_channel(
|
|
489
|
+
channel_config,
|
|
490
|
+
display_name,
|
|
491
|
+
credential_resolver,
|
|
492
|
+
)
|
|
493
|
+
else:
|
|
494
|
+
raise AlertConfigurationError(f"Unknown channel type '{channel_type}' for channel '{config_key}'")
|
|
495
|
+
|
|
496
|
+
manager.add_channel(
|
|
497
|
+
channel,
|
|
498
|
+
min_level=min_level,
|
|
499
|
+
throttle=channel_throttle,
|
|
500
|
+
key=config_key,
|
|
501
|
+
alias=channel_alias,
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
except Exception as e:
|
|
505
|
+
if isinstance(e, AlertConfigurationError):
|
|
506
|
+
raise
|
|
507
|
+
raise AlertConfigurationError(f"Failed to configure channel '{config_key}': {e}") from e
|
|
508
|
+
|
|
509
|
+
return manager
|
|
510
|
+
|
|
511
|
+
def __repr__(self) -> str:
|
|
512
|
+
"""Return string representation."""
|
|
513
|
+
return f"AlertManager(channels={len(self._channels)})"
|
|
514
|
+
|
|
515
|
+
|
|
516
|
+
def _parse_level(level_str: str) -> AlertLevel:
|
|
517
|
+
"""Parse alert level from string.
|
|
518
|
+
|
|
519
|
+
Args:
|
|
520
|
+
level_str: Level name (case-insensitive).
|
|
521
|
+
|
|
522
|
+
Returns:
|
|
523
|
+
AlertLevel enum value.
|
|
524
|
+
|
|
525
|
+
Raises:
|
|
526
|
+
AlertConfigurationError: If level is invalid.
|
|
527
|
+
"""
|
|
528
|
+
level_map = {
|
|
529
|
+
"info": AlertLevel.INFO,
|
|
530
|
+
"warning": AlertLevel.WARNING,
|
|
531
|
+
"critical": AlertLevel.CRITICAL,
|
|
532
|
+
}
|
|
533
|
+
level = level_map.get(level_str.lower())
|
|
534
|
+
if level is None:
|
|
535
|
+
raise AlertConfigurationError(f"Invalid alert level '{level_str}'. Valid levels: {', '.join(level_map.keys())}")
|
|
536
|
+
return level
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _create_email_transport(
|
|
540
|
+
transport_config: Mapping[str, Any],
|
|
541
|
+
name: str,
|
|
542
|
+
credential_resolver: CredentialResolver | None,
|
|
543
|
+
) -> MailTransport | AsyncMailTransport:
|
|
544
|
+
"""Create a mail transport from configuration.
|
|
545
|
+
|
|
546
|
+
Args:
|
|
547
|
+
transport_config: Transport configuration dict.
|
|
548
|
+
name: Channel name for error messages.
|
|
549
|
+
credential_resolver: Credential resolver.
|
|
550
|
+
|
|
551
|
+
Returns:
|
|
552
|
+
Configured mail transport.
|
|
553
|
+
|
|
554
|
+
Raises:
|
|
555
|
+
AlertConfigurationError: If configuration is invalid.
|
|
556
|
+
"""
|
|
557
|
+
transport_type = transport_config.get("type", "smtp").lower()
|
|
558
|
+
|
|
559
|
+
if transport_type == "smtp":
|
|
560
|
+
from kstlib.mail.transports import SMTPCredentials, SMTPSecurity, SMTPTransport
|
|
561
|
+
|
|
562
|
+
credentials = None
|
|
563
|
+
username = transport_config.get("username")
|
|
564
|
+
if username:
|
|
565
|
+
credentials = SMTPCredentials(
|
|
566
|
+
username=username,
|
|
567
|
+
password=transport_config.get("password"),
|
|
568
|
+
)
|
|
569
|
+
|
|
570
|
+
security = SMTPSecurity(
|
|
571
|
+
use_starttls=transport_config.get("use_tls", True),
|
|
572
|
+
)
|
|
573
|
+
|
|
574
|
+
return SMTPTransport(
|
|
575
|
+
host=transport_config.get("host", "localhost"),
|
|
576
|
+
port=int(transport_config.get("port", 587)),
|
|
577
|
+
credentials=credentials,
|
|
578
|
+
security=security,
|
|
579
|
+
)
|
|
580
|
+
|
|
581
|
+
if transport_type == "gmail":
|
|
582
|
+
# Gmail requires OAuth2 Token from kstlib.auth module
|
|
583
|
+
# Use GmailTransport directly with a Token object in code
|
|
584
|
+
raise AlertConfigurationError(
|
|
585
|
+
f"Gmail transport for '{name}' requires programmatic configuration. "
|
|
586
|
+
"Use GmailTransport(token=...) directly instead of config."
|
|
587
|
+
)
|
|
588
|
+
|
|
589
|
+
if transport_type == "resend":
|
|
590
|
+
from kstlib.mail.transports import ResendTransport
|
|
591
|
+
|
|
592
|
+
api_key = transport_config.get("api_key")
|
|
593
|
+
if not api_key and credential_resolver:
|
|
594
|
+
cred_name = transport_config.get("credentials")
|
|
595
|
+
if cred_name:
|
|
596
|
+
record = credential_resolver.resolve(cred_name)
|
|
597
|
+
api_key = record.value
|
|
598
|
+
|
|
599
|
+
if not api_key:
|
|
600
|
+
raise AlertConfigurationError(f"Resend transport for '{name}' requires 'api_key' or 'credentials'")
|
|
601
|
+
|
|
602
|
+
return ResendTransport(api_key=api_key)
|
|
603
|
+
|
|
604
|
+
raise AlertConfigurationError(f"Unknown transport type '{transport_type}' for email channel '{name}'")
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def _create_email_channel(
|
|
608
|
+
config: Mapping[str, Any],
|
|
609
|
+
name: str,
|
|
610
|
+
credential_resolver: CredentialResolver | None,
|
|
611
|
+
) -> AlertChannel | AsyncAlertChannel:
|
|
612
|
+
"""Create an EmailChannel from config.
|
|
613
|
+
|
|
614
|
+
Args:
|
|
615
|
+
config: Channel configuration.
|
|
616
|
+
name: Channel name.
|
|
617
|
+
credential_resolver: Credential resolver.
|
|
618
|
+
|
|
619
|
+
Returns:
|
|
620
|
+
Configured EmailChannel.
|
|
621
|
+
|
|
622
|
+
Raises:
|
|
623
|
+
AlertConfigurationError: If configuration is invalid.
|
|
624
|
+
"""
|
|
625
|
+
from kstlib.alerts.channels import EmailChannel
|
|
626
|
+
|
|
627
|
+
transport_config = config.get("transport")
|
|
628
|
+
if not transport_config:
|
|
629
|
+
raise AlertConfigurationError(f"Email channel '{name}' requires 'transport' configuration")
|
|
630
|
+
|
|
631
|
+
try:
|
|
632
|
+
transport = _create_email_transport(transport_config, name, credential_resolver)
|
|
633
|
+
except ImportError as e:
|
|
634
|
+
transport_type = transport_config.get("type", "smtp")
|
|
635
|
+
raise AlertConfigurationError(f"Missing dependency for transport '{transport_type}': {e}") from e
|
|
636
|
+
|
|
637
|
+
sender = config.get("sender")
|
|
638
|
+
if not sender:
|
|
639
|
+
raise AlertConfigurationError(f"Email channel '{name}' requires 'sender'")
|
|
640
|
+
|
|
641
|
+
recipients = config.get("recipients", [])
|
|
642
|
+
if not recipients:
|
|
643
|
+
raise AlertConfigurationError(f"Email channel '{name}' requires 'recipients'")
|
|
644
|
+
|
|
645
|
+
return EmailChannel(
|
|
646
|
+
transport=transport,
|
|
647
|
+
sender=sender,
|
|
648
|
+
recipients=recipients,
|
|
649
|
+
subject_prefix=config.get("subject_prefix", "[ALERT]"),
|
|
650
|
+
channel_name=name,
|
|
651
|
+
)
|