kstlib 0.0.1a0__py3-none-any.whl → 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.
- 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.0.dist-info/METADATA +201 -0
- kstlib-1.0.0.dist-info/RECORD +163 -0
- {kstlib-0.0.1a0.dist-info → kstlib-1.0.0.dist-info}/WHEEL +1 -1
- kstlib-1.0.0.dist-info/entry_points.txt +2 -0
- kstlib-1.0.0.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.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
"""Email channel for alert delivery.
|
|
2
|
+
|
|
3
|
+
Sends alerts via email using kstlib.mail transports. Supports both
|
|
4
|
+
sync and async transports with automatic wrapping.
|
|
5
|
+
|
|
6
|
+
Examples:
|
|
7
|
+
With SMTP transport::
|
|
8
|
+
|
|
9
|
+
from kstlib.alerts.channels import EmailChannel
|
|
10
|
+
from kstlib.mail.transports import SMTPTransport
|
|
11
|
+
|
|
12
|
+
transport = SMTPTransport(host="smtp.example.com", port=587)
|
|
13
|
+
channel = EmailChannel(
|
|
14
|
+
transport=transport,
|
|
15
|
+
sender="alerts@example.com",
|
|
16
|
+
recipients=["oncall@example.com"],
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
result = await channel.send(alert)
|
|
20
|
+
|
|
21
|
+
With async transport (Resend)::
|
|
22
|
+
|
|
23
|
+
from kstlib.mail.transports import ResendTransport
|
|
24
|
+
|
|
25
|
+
transport = ResendTransport(api_key="re_xxx")
|
|
26
|
+
channel = EmailChannel(
|
|
27
|
+
transport=transport,
|
|
28
|
+
sender="alerts@example.com",
|
|
29
|
+
recipients=["oncall@example.com", "backup@example.com"],
|
|
30
|
+
subject_prefix="[PROD ALERT]",
|
|
31
|
+
)
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import logging
|
|
37
|
+
from email.message import EmailMessage
|
|
38
|
+
from typing import TYPE_CHECKING
|
|
39
|
+
|
|
40
|
+
from kstlib.alerts.channels.base import AsyncAlertChannel
|
|
41
|
+
from kstlib.alerts.exceptions import AlertConfigurationError, AlertDeliveryError
|
|
42
|
+
from kstlib.alerts.models import AlertLevel, AlertResult
|
|
43
|
+
from kstlib.mail.transport import AsyncMailTransport, AsyncTransportWrapper, MailTransport
|
|
44
|
+
|
|
45
|
+
if TYPE_CHECKING:
|
|
46
|
+
from collections.abc import Sequence
|
|
47
|
+
|
|
48
|
+
from kstlib.alerts.models import AlertMessage
|
|
49
|
+
|
|
50
|
+
__all__ = ["EmailChannel"]
|
|
51
|
+
|
|
52
|
+
log = logging.getLogger(__name__)
|
|
53
|
+
|
|
54
|
+
# Subject prefix emoji for alert levels
|
|
55
|
+
LEVEL_PREFIX = {
|
|
56
|
+
AlertLevel.INFO: "[INFO]",
|
|
57
|
+
AlertLevel.WARNING: "[WARNING]",
|
|
58
|
+
AlertLevel.CRITICAL: "[CRITICAL]",
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class EmailChannel(AsyncAlertChannel):
|
|
63
|
+
"""Async channel for sending alerts via email.
|
|
64
|
+
|
|
65
|
+
Wraps kstlib.mail transports to send alert messages as emails.
|
|
66
|
+
Sync transports are automatically wrapped for async usage.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
transport: Mail transport (sync or async).
|
|
70
|
+
sender: Email sender address.
|
|
71
|
+
recipients: List of recipient email addresses.
|
|
72
|
+
subject_prefix: Prefix for email subjects (default: '[ALERT]').
|
|
73
|
+
channel_name: Optional name override for this channel.
|
|
74
|
+
|
|
75
|
+
Raises:
|
|
76
|
+
AlertConfigurationError: If configuration is invalid.
|
|
77
|
+
|
|
78
|
+
Examples:
|
|
79
|
+
With SMTP::
|
|
80
|
+
|
|
81
|
+
transport = SMTPTransport(host="smtp.example.com", port=587)
|
|
82
|
+
channel = EmailChannel(
|
|
83
|
+
transport=transport,
|
|
84
|
+
sender="alerts@example.com",
|
|
85
|
+
recipients=["team@example.com"],
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
With Gmail::
|
|
89
|
+
|
|
90
|
+
from kstlib.mail.transports import GmailOAuth2Transport
|
|
91
|
+
|
|
92
|
+
transport = GmailOAuth2Transport.from_config(config)
|
|
93
|
+
channel = EmailChannel(
|
|
94
|
+
transport=transport,
|
|
95
|
+
sender="alerts@company.com",
|
|
96
|
+
recipients=["oncall@company.com"],
|
|
97
|
+
subject_prefix="[PROD]",
|
|
98
|
+
)
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
def __init__(
|
|
102
|
+
self,
|
|
103
|
+
transport: MailTransport | AsyncMailTransport,
|
|
104
|
+
*,
|
|
105
|
+
sender: str,
|
|
106
|
+
recipients: Sequence[str],
|
|
107
|
+
subject_prefix: str = "[ALERT]",
|
|
108
|
+
channel_name: str | None = None,
|
|
109
|
+
) -> None:
|
|
110
|
+
"""Initialize EmailChannel.
|
|
111
|
+
|
|
112
|
+
Args:
|
|
113
|
+
transport: Mail transport (sync or async).
|
|
114
|
+
sender: Email sender address.
|
|
115
|
+
recipients: List of recipient email addresses.
|
|
116
|
+
subject_prefix: Prefix for email subjects.
|
|
117
|
+
channel_name: Optional name override for this channel.
|
|
118
|
+
|
|
119
|
+
Raises:
|
|
120
|
+
AlertConfigurationError: If configuration is invalid.
|
|
121
|
+
"""
|
|
122
|
+
if not sender:
|
|
123
|
+
raise AlertConfigurationError("Email sender is required")
|
|
124
|
+
|
|
125
|
+
if not recipients:
|
|
126
|
+
raise AlertConfigurationError("At least one recipient is required")
|
|
127
|
+
|
|
128
|
+
# Wrap sync transport for async usage
|
|
129
|
+
if isinstance(transport, MailTransport):
|
|
130
|
+
self._transport: AsyncMailTransport = AsyncTransportWrapper(transport)
|
|
131
|
+
else:
|
|
132
|
+
self._transport = transport
|
|
133
|
+
|
|
134
|
+
self._sender = sender
|
|
135
|
+
self._recipients = list(recipients)
|
|
136
|
+
self._subject_prefix = subject_prefix
|
|
137
|
+
self._channel_name = channel_name or "email"
|
|
138
|
+
|
|
139
|
+
log.debug(
|
|
140
|
+
"EmailChannel initialized: sender=%s, recipients=%d",
|
|
141
|
+
sender,
|
|
142
|
+
len(self._recipients),
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
@property
|
|
146
|
+
def name(self) -> str:
|
|
147
|
+
"""Return the channel name."""
|
|
148
|
+
return self._channel_name
|
|
149
|
+
|
|
150
|
+
async def send(self, alert: AlertMessage) -> AlertResult:
|
|
151
|
+
"""Send an alert via email.
|
|
152
|
+
|
|
153
|
+
Constructs an email message with appropriate subject and body
|
|
154
|
+
formatting based on the alert level.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
alert: The alert message to send.
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
AlertResult with delivery status.
|
|
161
|
+
|
|
162
|
+
Raises:
|
|
163
|
+
AlertDeliveryError: If email delivery fails.
|
|
164
|
+
"""
|
|
165
|
+
message = self._build_message(alert)
|
|
166
|
+
|
|
167
|
+
log.debug(
|
|
168
|
+
"Sending alert email: level=%s, recipients=%d",
|
|
169
|
+
alert.level.name,
|
|
170
|
+
len(self._recipients),
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
try:
|
|
174
|
+
await self._transport.send(message)
|
|
175
|
+
|
|
176
|
+
log.debug("Alert email sent successfully")
|
|
177
|
+
return AlertResult(
|
|
178
|
+
channel=self.name,
|
|
179
|
+
success=True,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
except Exception as e:
|
|
183
|
+
log.warning("Email delivery failed: %s", e)
|
|
184
|
+
raise AlertDeliveryError(
|
|
185
|
+
f"Email delivery failed: {e}",
|
|
186
|
+
channel=self.name,
|
|
187
|
+
retryable=True,
|
|
188
|
+
) from e
|
|
189
|
+
|
|
190
|
+
def _build_message(self, alert: AlertMessage) -> EmailMessage:
|
|
191
|
+
"""Build email message from alert.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
alert: The alert message.
|
|
195
|
+
|
|
196
|
+
Returns:
|
|
197
|
+
EmailMessage ready for transport.
|
|
198
|
+
"""
|
|
199
|
+
message = EmailMessage()
|
|
200
|
+
|
|
201
|
+
# Build subject with level indicator (use formatted_title for timestamp)
|
|
202
|
+
level_prefix = LEVEL_PREFIX.get(alert.level, "[ALERT]")
|
|
203
|
+
subject = f"{self._subject_prefix} {level_prefix} {alert.formatted_title}"
|
|
204
|
+
message["Subject"] = subject
|
|
205
|
+
|
|
206
|
+
message["From"] = self._sender
|
|
207
|
+
message["To"] = ", ".join(self._recipients)
|
|
208
|
+
|
|
209
|
+
# Build body with level context
|
|
210
|
+
formatted = alert.formatted_title
|
|
211
|
+
body = f"""Alert Level: {alert.level.name}
|
|
212
|
+
|
|
213
|
+
{formatted}
|
|
214
|
+
{"=" * len(formatted)}
|
|
215
|
+
|
|
216
|
+
{alert.body}
|
|
217
|
+
|
|
218
|
+
---
|
|
219
|
+
Sent by kstlib.alerts
|
|
220
|
+
"""
|
|
221
|
+
message.set_content(body)
|
|
222
|
+
|
|
223
|
+
return message
|
|
224
|
+
|
|
225
|
+
def __repr__(self) -> str:
|
|
226
|
+
"""Return string representation."""
|
|
227
|
+
return f"EmailChannel(sender={self._sender!r}, recipients={len(self._recipients)}, name={self._channel_name!r})"
|
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
"""Slack webhook channel for alert delivery.
|
|
2
|
+
|
|
3
|
+
Sends alerts to Slack via incoming webhook URLs. Supports all alert
|
|
4
|
+
levels with appropriate formatting and emoji indicators.
|
|
5
|
+
|
|
6
|
+
Requirements:
|
|
7
|
+
pip install httpx
|
|
8
|
+
|
|
9
|
+
Security:
|
|
10
|
+
- Webhook URLs are validated to prevent SSRF
|
|
11
|
+
- URLs are masked in logs and repr output
|
|
12
|
+
- Payloads are truncated to Slack limits
|
|
13
|
+
|
|
14
|
+
Examples:
|
|
15
|
+
Basic usage::
|
|
16
|
+
|
|
17
|
+
from kstlib.alerts.channels import SlackChannel
|
|
18
|
+
from kstlib.alerts.models import AlertMessage, AlertLevel
|
|
19
|
+
|
|
20
|
+
channel = SlackChannel(
|
|
21
|
+
webhook_url="https://hooks.slack.com/services/T.../B.../xxx"
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
alert = AlertMessage(
|
|
25
|
+
title="Deployment Complete",
|
|
26
|
+
body="Version 2.1.0 deployed to production",
|
|
27
|
+
level=AlertLevel.INFO,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
result = await channel.send(alert)
|
|
31
|
+
|
|
32
|
+
With SOPS credentials::
|
|
33
|
+
|
|
34
|
+
channel = SlackChannel.from_config(
|
|
35
|
+
config={"credentials": "slack_webhook"},
|
|
36
|
+
credential_resolver=resolver,
|
|
37
|
+
)
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import logging
|
|
43
|
+
import re
|
|
44
|
+
from typing import TYPE_CHECKING, Any
|
|
45
|
+
|
|
46
|
+
from kstlib.alerts.channels.base import AsyncAlertChannel
|
|
47
|
+
from kstlib.alerts.exceptions import AlertConfigurationError, AlertDeliveryError
|
|
48
|
+
from kstlib.alerts.models import AlertLevel, AlertResult
|
|
49
|
+
from kstlib.limits import (
|
|
50
|
+
HARD_MAX_CHANNEL_TIMEOUT,
|
|
51
|
+
HARD_MIN_CHANNEL_TIMEOUT,
|
|
52
|
+
clamp_with_limits,
|
|
53
|
+
get_alerts_limits,
|
|
54
|
+
)
|
|
55
|
+
from kstlib.ssl import build_ssl_context
|
|
56
|
+
|
|
57
|
+
if TYPE_CHECKING:
|
|
58
|
+
from collections.abc import Mapping
|
|
59
|
+
|
|
60
|
+
from kstlib.alerts.models import AlertMessage
|
|
61
|
+
from kstlib.rapi.credentials import CredentialResolver
|
|
62
|
+
|
|
63
|
+
__all__ = ["SlackChannel"]
|
|
64
|
+
|
|
65
|
+
log = logging.getLogger(__name__)
|
|
66
|
+
|
|
67
|
+
# Slack limits
|
|
68
|
+
MAX_TITLE_LENGTH = 150
|
|
69
|
+
MAX_BODY_LENGTH = 3000
|
|
70
|
+
|
|
71
|
+
# Trusted Slack webhook URL pattern
|
|
72
|
+
SLACK_WEBHOOK_PATTERN = re.compile(r"^https://hooks\.slack\.com/services/T[A-Z0-9]+/B[A-Z0-9]+/[a-zA-Z0-9]+$")
|
|
73
|
+
|
|
74
|
+
# Emoji indicators for alert levels
|
|
75
|
+
LEVEL_EMOJI = {
|
|
76
|
+
AlertLevel.INFO: ":information_source:",
|
|
77
|
+
AlertLevel.WARNING: ":warning:",
|
|
78
|
+
AlertLevel.CRITICAL: ":rotating_light:",
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
# Color indicators for alert levels (Slack attachment colors)
|
|
82
|
+
LEVEL_COLOR = {
|
|
83
|
+
AlertLevel.INFO: "#36a64f", # Green
|
|
84
|
+
AlertLevel.WARNING: "#ff9800", # Orange
|
|
85
|
+
AlertLevel.CRITICAL: "#ff0000", # Red
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _mask_webhook_url(url: str) -> str:
|
|
90
|
+
"""Mask a webhook URL for safe logging.
|
|
91
|
+
|
|
92
|
+
Args:
|
|
93
|
+
url: The full webhook URL.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
Masked URL like 'https://hooks.slack.com/services/T***/B***/***'.
|
|
97
|
+
|
|
98
|
+
Examples:
|
|
99
|
+
>>> _mask_webhook_url("https://hooks.slack.com/services/T123/B456/xyz")
|
|
100
|
+
'https://hooks.slack.com/services/T***/B***/***'
|
|
101
|
+
"""
|
|
102
|
+
if not url or "hooks.slack.com" not in url:
|
|
103
|
+
return "***"
|
|
104
|
+
|
|
105
|
+
# Extract base and mask the sensitive parts
|
|
106
|
+
parts = url.split("/services/")
|
|
107
|
+
if len(parts) == 2:
|
|
108
|
+
tokens = parts[1].split("/")
|
|
109
|
+
if len(tokens) >= 3:
|
|
110
|
+
return f"https://hooks.slack.com/services/{tokens[0][:1]}***/{tokens[1][:1]}***/***"
|
|
111
|
+
|
|
112
|
+
return "https://hooks.slack.com/services/***"
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _truncate(text: str, max_length: int) -> str:
|
|
116
|
+
"""Truncate text to max length with ellipsis.
|
|
117
|
+
|
|
118
|
+
Args:
|
|
119
|
+
text: Text to truncate.
|
|
120
|
+
max_length: Maximum allowed length.
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
Truncated text with '...' if exceeded.
|
|
124
|
+
"""
|
|
125
|
+
if len(text) <= max_length:
|
|
126
|
+
return text
|
|
127
|
+
return text[: max_length - 3] + "..."
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class SlackChannel(AsyncAlertChannel):
|
|
131
|
+
"""Async channel for sending alerts to Slack via webhooks.
|
|
132
|
+
|
|
133
|
+
Uses Slack's incoming webhook API to post formatted messages.
|
|
134
|
+
Supports customizable username, emoji, and timeout settings.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
webhook_url: Slack incoming webhook URL.
|
|
138
|
+
username: Bot username shown in Slack (default: 'kstlib-alerts').
|
|
139
|
+
icon_emoji: Emoji for bot avatar (default: ':bell:').
|
|
140
|
+
timeout: HTTP request timeout in seconds (default: 10.0).
|
|
141
|
+
channel_name: Optional name override for this channel.
|
|
142
|
+
|
|
143
|
+
Raises:
|
|
144
|
+
AlertConfigurationError: If webhook_url is invalid.
|
|
145
|
+
|
|
146
|
+
Examples:
|
|
147
|
+
Basic usage::
|
|
148
|
+
|
|
149
|
+
channel = SlackChannel(
|
|
150
|
+
webhook_url="https://hooks.slack.com/services/T.../B.../xxx"
|
|
151
|
+
)
|
|
152
|
+
result = await channel.send(alert)
|
|
153
|
+
|
|
154
|
+
Custom settings::
|
|
155
|
+
|
|
156
|
+
channel = SlackChannel(
|
|
157
|
+
webhook_url="https://hooks.slack.com/services/T.../B.../xxx",
|
|
158
|
+
username="prod-alerts",
|
|
159
|
+
icon_emoji=":fire:",
|
|
160
|
+
timeout=5.0,
|
|
161
|
+
)
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
def __init__(
|
|
165
|
+
self,
|
|
166
|
+
webhook_url: str,
|
|
167
|
+
*,
|
|
168
|
+
username: str = "kstlib-alerts",
|
|
169
|
+
icon_emoji: str = ":bell:",
|
|
170
|
+
timeout: float | None = None,
|
|
171
|
+
channel_name: str | None = None,
|
|
172
|
+
ssl_verify: bool | None = None,
|
|
173
|
+
ssl_ca_bundle: str | None = None,
|
|
174
|
+
) -> None:
|
|
175
|
+
"""Initialize SlackChannel.
|
|
176
|
+
|
|
177
|
+
Args:
|
|
178
|
+
webhook_url: Slack incoming webhook URL.
|
|
179
|
+
username: Bot username shown in Slack.
|
|
180
|
+
icon_emoji: Emoji for bot avatar.
|
|
181
|
+
timeout: HTTP request timeout in seconds. If None, uses config.
|
|
182
|
+
Hard limits: [1.0, 120.0].
|
|
183
|
+
channel_name: Optional name override for this channel.
|
|
184
|
+
ssl_verify: Override SSL verification (True/False).
|
|
185
|
+
If None, uses global config from kstlib.conf.yml.
|
|
186
|
+
ssl_ca_bundle: Override CA bundle path.
|
|
187
|
+
If None, uses global config from kstlib.conf.yml.
|
|
188
|
+
|
|
189
|
+
Raises:
|
|
190
|
+
AlertConfigurationError: If webhook_url is invalid.
|
|
191
|
+
"""
|
|
192
|
+
if not webhook_url:
|
|
193
|
+
raise AlertConfigurationError("Slack webhook URL is required")
|
|
194
|
+
|
|
195
|
+
if not SLACK_WEBHOOK_PATTERN.match(webhook_url):
|
|
196
|
+
raise AlertConfigurationError(
|
|
197
|
+
"Invalid Slack webhook URL. Must match pattern: https://hooks.slack.com/services/T.../B.../..."
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
# Load config defaults and apply clamping
|
|
201
|
+
limits = get_alerts_limits()
|
|
202
|
+
resolved_timeout = clamp_with_limits(
|
|
203
|
+
timeout if timeout is not None else limits.channel_timeout,
|
|
204
|
+
HARD_MIN_CHANNEL_TIMEOUT,
|
|
205
|
+
HARD_MAX_CHANNEL_TIMEOUT,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
self._webhook_url = webhook_url
|
|
209
|
+
self._username = username
|
|
210
|
+
self._icon_emoji = icon_emoji
|
|
211
|
+
self._timeout = resolved_timeout
|
|
212
|
+
self._channel_name = channel_name or "slack"
|
|
213
|
+
|
|
214
|
+
# Build SSL context (cascade: kwargs > global config > default)
|
|
215
|
+
self._ssl_context = build_ssl_context(
|
|
216
|
+
ssl_verify=ssl_verify,
|
|
217
|
+
ssl_ca_bundle=ssl_ca_bundle,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
log.debug("SlackChannel initialized: %s", _mask_webhook_url(webhook_url))
|
|
221
|
+
|
|
222
|
+
@property
|
|
223
|
+
def name(self) -> str:
|
|
224
|
+
"""Return the channel name."""
|
|
225
|
+
return self._channel_name
|
|
226
|
+
|
|
227
|
+
async def send(self, alert: AlertMessage) -> AlertResult:
|
|
228
|
+
"""Send an alert to Slack via webhook.
|
|
229
|
+
|
|
230
|
+
Formats the alert as a Slack message with appropriate emoji
|
|
231
|
+
and color based on the alert level.
|
|
232
|
+
|
|
233
|
+
Args:
|
|
234
|
+
alert: The alert message to send.
|
|
235
|
+
|
|
236
|
+
Returns:
|
|
237
|
+
AlertResult with delivery status.
|
|
238
|
+
|
|
239
|
+
Raises:
|
|
240
|
+
AlertDeliveryError: If the webhook request fails.
|
|
241
|
+
"""
|
|
242
|
+
try:
|
|
243
|
+
import httpx
|
|
244
|
+
except ImportError as e:
|
|
245
|
+
raise AlertConfigurationError("httpx is required for SlackChannel. Install with: pip install httpx") from e
|
|
246
|
+
|
|
247
|
+
payload = self._build_payload(alert)
|
|
248
|
+
|
|
249
|
+
log.debug(
|
|
250
|
+
"Sending alert to Slack: level=%s, title=%r",
|
|
251
|
+
alert.level.name,
|
|
252
|
+
_truncate(alert.title, 50),
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
try:
|
|
256
|
+
async with httpx.AsyncClient(timeout=self._timeout, verify=self._ssl_context) as client:
|
|
257
|
+
response = await client.post(
|
|
258
|
+
self._webhook_url,
|
|
259
|
+
json=payload,
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
if response.status_code != 200:
|
|
263
|
+
error_msg = response.text or f"HTTP {response.status_code}"
|
|
264
|
+
log.warning(
|
|
265
|
+
"Slack webhook failed: status=%d, error=%s",
|
|
266
|
+
response.status_code,
|
|
267
|
+
error_msg,
|
|
268
|
+
)
|
|
269
|
+
raise AlertDeliveryError(
|
|
270
|
+
f"Slack webhook failed: {error_msg}",
|
|
271
|
+
channel=self.name,
|
|
272
|
+
retryable=response.status_code >= 500,
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
log.debug("Alert sent to Slack successfully")
|
|
276
|
+
return AlertResult(
|
|
277
|
+
channel=self.name,
|
|
278
|
+
success=True,
|
|
279
|
+
)
|
|
280
|
+
|
|
281
|
+
except httpx.TimeoutException as e:
|
|
282
|
+
log.warning("Slack webhook timeout")
|
|
283
|
+
raise AlertDeliveryError(
|
|
284
|
+
f"Slack webhook timeout: {e}",
|
|
285
|
+
channel=self.name,
|
|
286
|
+
retryable=True,
|
|
287
|
+
) from e
|
|
288
|
+
except httpx.RequestError as e:
|
|
289
|
+
log.warning("Slack webhook request failed: %s", e)
|
|
290
|
+
raise AlertDeliveryError(
|
|
291
|
+
f"Slack webhook request failed: {e}",
|
|
292
|
+
channel=self.name,
|
|
293
|
+
retryable=True,
|
|
294
|
+
) from e
|
|
295
|
+
|
|
296
|
+
def _build_payload(self, alert: AlertMessage) -> dict[str, Any]:
|
|
297
|
+
"""Build Slack webhook payload from alert.
|
|
298
|
+
|
|
299
|
+
Args:
|
|
300
|
+
alert: The alert message.
|
|
301
|
+
|
|
302
|
+
Returns:
|
|
303
|
+
Dict suitable for JSON serialization.
|
|
304
|
+
"""
|
|
305
|
+
# Truncate to Slack limits (use formatted_title for timestamp support)
|
|
306
|
+
title = _truncate(alert.formatted_title, MAX_TITLE_LENGTH)
|
|
307
|
+
body = _truncate(alert.body, MAX_BODY_LENGTH)
|
|
308
|
+
|
|
309
|
+
emoji = LEVEL_EMOJI.get(alert.level, ":bell:")
|
|
310
|
+
color = LEVEL_COLOR.get(alert.level, "#808080")
|
|
311
|
+
|
|
312
|
+
return {
|
|
313
|
+
"username": self._username,
|
|
314
|
+
"icon_emoji": self._icon_emoji,
|
|
315
|
+
"attachments": [
|
|
316
|
+
{
|
|
317
|
+
"color": color,
|
|
318
|
+
"title": f"{emoji} {title}",
|
|
319
|
+
"text": body,
|
|
320
|
+
"footer": f"Alert Level: {alert.level.name}",
|
|
321
|
+
}
|
|
322
|
+
],
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
@classmethod
|
|
326
|
+
def from_config(
|
|
327
|
+
cls,
|
|
328
|
+
config: Mapping[str, Any],
|
|
329
|
+
credential_resolver: CredentialResolver | None = None,
|
|
330
|
+
) -> SlackChannel:
|
|
331
|
+
"""Create SlackChannel from configuration dict.
|
|
332
|
+
|
|
333
|
+
Config format::
|
|
334
|
+
|
|
335
|
+
slack_ops:
|
|
336
|
+
type: slack
|
|
337
|
+
credentials: slack_webhook # reference to credentials section
|
|
338
|
+
username: "kstlib-alerts"
|
|
339
|
+
icon_emoji: ":bell:"
|
|
340
|
+
timeout: 10.0
|
|
341
|
+
|
|
342
|
+
The webhook URL is resolved via credential_resolver using the
|
|
343
|
+
'credentials' key, supporting env, file, and SOPS sources.
|
|
344
|
+
|
|
345
|
+
Args:
|
|
346
|
+
config: Channel configuration dict.
|
|
347
|
+
credential_resolver: Resolver for credential references.
|
|
348
|
+
|
|
349
|
+
Returns:
|
|
350
|
+
Configured SlackChannel instance.
|
|
351
|
+
|
|
352
|
+
Raises:
|
|
353
|
+
AlertConfigurationError: If configuration is invalid.
|
|
354
|
+
"""
|
|
355
|
+
# Get webhook URL from credentials
|
|
356
|
+
cred_name = config.get("credentials")
|
|
357
|
+
webhook_url = config.get("webhook_url")
|
|
358
|
+
|
|
359
|
+
if cred_name and credential_resolver:
|
|
360
|
+
try:
|
|
361
|
+
record = credential_resolver.resolve(cred_name)
|
|
362
|
+
webhook_url = record.value
|
|
363
|
+
except Exception as e:
|
|
364
|
+
raise AlertConfigurationError(f"Failed to resolve Slack credentials '{cred_name}': {e}") from e
|
|
365
|
+
elif not webhook_url:
|
|
366
|
+
raise AlertConfigurationError("SlackChannel requires 'credentials' or 'webhook_url' in config")
|
|
367
|
+
|
|
368
|
+
# Parse timeout: None means use config default
|
|
369
|
+
timeout_raw = config.get("timeout")
|
|
370
|
+
timeout = float(timeout_raw) if timeout_raw is not None else None
|
|
371
|
+
|
|
372
|
+
# Parse SSL settings: None means use global config
|
|
373
|
+
ssl_verify_raw = config.get("ssl_verify")
|
|
374
|
+
ssl_verify = bool(ssl_verify_raw) if ssl_verify_raw is not None else None
|
|
375
|
+
ssl_ca_bundle = config.get("ssl_ca_bundle")
|
|
376
|
+
|
|
377
|
+
return cls(
|
|
378
|
+
webhook_url=webhook_url,
|
|
379
|
+
username=config.get("username", "kstlib-alerts"),
|
|
380
|
+
icon_emoji=config.get("icon_emoji", ":bell:"),
|
|
381
|
+
timeout=timeout,
|
|
382
|
+
channel_name=config.get("name"),
|
|
383
|
+
ssl_verify=ssl_verify,
|
|
384
|
+
ssl_ca_bundle=ssl_ca_bundle,
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
def __repr__(self) -> str:
|
|
388
|
+
"""Return string representation without secrets."""
|
|
389
|
+
return f"SlackChannel(username={self._username!r}, name={self._channel_name!r})"
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Custom exceptions for the alerts module."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from kstlib.config.exceptions import KstlibError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class AlertError(KstlibError):
|
|
9
|
+
"""Base class for all alert-related errors."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AlertConfigurationError(AlertError):
|
|
13
|
+
"""Raised when alert configuration is invalid or incomplete."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class AlertDeliveryError(AlertError):
|
|
17
|
+
"""Raised when an alert cannot be delivered to a channel.
|
|
18
|
+
|
|
19
|
+
Attributes:
|
|
20
|
+
channel: The name of the channel that failed.
|
|
21
|
+
retryable: Whether the error is potentially recoverable with retry.
|
|
22
|
+
|
|
23
|
+
Examples:
|
|
24
|
+
>>> err = AlertDeliveryError("Connection failed", channel="slack", retryable=True)
|
|
25
|
+
>>> err.channel
|
|
26
|
+
'slack'
|
|
27
|
+
>>> err.retryable
|
|
28
|
+
True
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, message: str, *, channel: str, retryable: bool = False) -> None:
|
|
32
|
+
"""Initialize AlertDeliveryError.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
message: Error description.
|
|
36
|
+
channel: Name of the channel that failed.
|
|
37
|
+
retryable: Whether the delivery could succeed on retry.
|
|
38
|
+
"""
|
|
39
|
+
super().__init__(message)
|
|
40
|
+
self.channel = channel
|
|
41
|
+
self.retryable = retryable
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class AlertThrottledError(AlertError):
|
|
45
|
+
"""Raised when an alert is throttled due to rate limiting.
|
|
46
|
+
|
|
47
|
+
Attributes:
|
|
48
|
+
retry_after: Seconds until the next alert can be sent.
|
|
49
|
+
|
|
50
|
+
Examples:
|
|
51
|
+
>>> err = AlertThrottledError("Rate limit exceeded", retry_after=30.0)
|
|
52
|
+
>>> err.retry_after
|
|
53
|
+
30.0
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
def __init__(self, message: str, *, retry_after: float) -> None:
|
|
57
|
+
"""Initialize AlertThrottledError.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
message: Error description.
|
|
61
|
+
retry_after: Seconds until the rate limit resets.
|
|
62
|
+
"""
|
|
63
|
+
super().__init__(message)
|
|
64
|
+
self.retry_after = retry_after
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
__all__ = [
|
|
68
|
+
"AlertConfigurationError",
|
|
69
|
+
"AlertDeliveryError",
|
|
70
|
+
"AlertError",
|
|
71
|
+
"AlertThrottledError",
|
|
72
|
+
]
|