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.
Files changed (166) hide show
  1. kstlib/__init__.py +266 -1
  2. kstlib/__main__.py +16 -0
  3. kstlib/alerts/__init__.py +110 -0
  4. kstlib/alerts/channels/__init__.py +36 -0
  5. kstlib/alerts/channels/base.py +197 -0
  6. kstlib/alerts/channels/email.py +227 -0
  7. kstlib/alerts/channels/slack.py +389 -0
  8. kstlib/alerts/exceptions.py +72 -0
  9. kstlib/alerts/manager.py +651 -0
  10. kstlib/alerts/models.py +142 -0
  11. kstlib/alerts/throttle.py +263 -0
  12. kstlib/auth/__init__.py +139 -0
  13. kstlib/auth/callback.py +399 -0
  14. kstlib/auth/config.py +502 -0
  15. kstlib/auth/errors.py +127 -0
  16. kstlib/auth/models.py +316 -0
  17. kstlib/auth/providers/__init__.py +14 -0
  18. kstlib/auth/providers/base.py +393 -0
  19. kstlib/auth/providers/oauth2.py +645 -0
  20. kstlib/auth/providers/oidc.py +821 -0
  21. kstlib/auth/session.py +338 -0
  22. kstlib/auth/token.py +482 -0
  23. kstlib/cache/__init__.py +50 -0
  24. kstlib/cache/decorator.py +261 -0
  25. kstlib/cache/strategies.py +516 -0
  26. kstlib/cli/__init__.py +8 -0
  27. kstlib/cli/app.py +195 -0
  28. kstlib/cli/commands/__init__.py +5 -0
  29. kstlib/cli/commands/auth/__init__.py +39 -0
  30. kstlib/cli/commands/auth/common.py +122 -0
  31. kstlib/cli/commands/auth/login.py +325 -0
  32. kstlib/cli/commands/auth/logout.py +74 -0
  33. kstlib/cli/commands/auth/providers.py +57 -0
  34. kstlib/cli/commands/auth/status.py +291 -0
  35. kstlib/cli/commands/auth/token.py +199 -0
  36. kstlib/cli/commands/auth/whoami.py +106 -0
  37. kstlib/cli/commands/config.py +89 -0
  38. kstlib/cli/commands/ops/__init__.py +39 -0
  39. kstlib/cli/commands/ops/attach.py +49 -0
  40. kstlib/cli/commands/ops/common.py +269 -0
  41. kstlib/cli/commands/ops/list_sessions.py +252 -0
  42. kstlib/cli/commands/ops/logs.py +49 -0
  43. kstlib/cli/commands/ops/start.py +98 -0
  44. kstlib/cli/commands/ops/status.py +138 -0
  45. kstlib/cli/commands/ops/stop.py +60 -0
  46. kstlib/cli/commands/rapi/__init__.py +60 -0
  47. kstlib/cli/commands/rapi/call.py +341 -0
  48. kstlib/cli/commands/rapi/list.py +99 -0
  49. kstlib/cli/commands/rapi/show.py +206 -0
  50. kstlib/cli/commands/secrets/__init__.py +35 -0
  51. kstlib/cli/commands/secrets/common.py +425 -0
  52. kstlib/cli/commands/secrets/decrypt.py +88 -0
  53. kstlib/cli/commands/secrets/doctor.py +743 -0
  54. kstlib/cli/commands/secrets/encrypt.py +242 -0
  55. kstlib/cli/commands/secrets/shred.py +96 -0
  56. kstlib/cli/common.py +86 -0
  57. kstlib/config/__init__.py +76 -0
  58. kstlib/config/exceptions.py +110 -0
  59. kstlib/config/export.py +225 -0
  60. kstlib/config/loader.py +963 -0
  61. kstlib/config/sops.py +287 -0
  62. kstlib/db/__init__.py +54 -0
  63. kstlib/db/aiosqlcipher.py +137 -0
  64. kstlib/db/cipher.py +112 -0
  65. kstlib/db/database.py +367 -0
  66. kstlib/db/exceptions.py +25 -0
  67. kstlib/db/pool.py +302 -0
  68. kstlib/helpers/__init__.py +35 -0
  69. kstlib/helpers/exceptions.py +11 -0
  70. kstlib/helpers/time_trigger.py +396 -0
  71. kstlib/kstlib.conf.yml +890 -0
  72. kstlib/limits.py +963 -0
  73. kstlib/logging/__init__.py +108 -0
  74. kstlib/logging/manager.py +633 -0
  75. kstlib/mail/__init__.py +42 -0
  76. kstlib/mail/builder.py +626 -0
  77. kstlib/mail/exceptions.py +27 -0
  78. kstlib/mail/filesystem.py +248 -0
  79. kstlib/mail/transport.py +224 -0
  80. kstlib/mail/transports/__init__.py +19 -0
  81. kstlib/mail/transports/gmail.py +268 -0
  82. kstlib/mail/transports/resend.py +324 -0
  83. kstlib/mail/transports/smtp.py +326 -0
  84. kstlib/meta.py +72 -0
  85. kstlib/metrics/__init__.py +88 -0
  86. kstlib/metrics/decorators.py +1090 -0
  87. kstlib/metrics/exceptions.py +14 -0
  88. kstlib/monitoring/__init__.py +116 -0
  89. kstlib/monitoring/_styles.py +163 -0
  90. kstlib/monitoring/cell.py +57 -0
  91. kstlib/monitoring/config.py +424 -0
  92. kstlib/monitoring/delivery.py +579 -0
  93. kstlib/monitoring/exceptions.py +63 -0
  94. kstlib/monitoring/image.py +220 -0
  95. kstlib/monitoring/kv.py +79 -0
  96. kstlib/monitoring/list.py +69 -0
  97. kstlib/monitoring/metric.py +88 -0
  98. kstlib/monitoring/monitoring.py +341 -0
  99. kstlib/monitoring/renderer.py +139 -0
  100. kstlib/monitoring/service.py +392 -0
  101. kstlib/monitoring/table.py +129 -0
  102. kstlib/monitoring/types.py +56 -0
  103. kstlib/ops/__init__.py +86 -0
  104. kstlib/ops/base.py +148 -0
  105. kstlib/ops/container.py +577 -0
  106. kstlib/ops/exceptions.py +209 -0
  107. kstlib/ops/manager.py +407 -0
  108. kstlib/ops/models.py +176 -0
  109. kstlib/ops/tmux.py +372 -0
  110. kstlib/ops/validators.py +287 -0
  111. kstlib/py.typed +0 -0
  112. kstlib/rapi/__init__.py +118 -0
  113. kstlib/rapi/client.py +875 -0
  114. kstlib/rapi/config.py +861 -0
  115. kstlib/rapi/credentials.py +887 -0
  116. kstlib/rapi/exceptions.py +213 -0
  117. kstlib/resilience/__init__.py +101 -0
  118. kstlib/resilience/circuit_breaker.py +440 -0
  119. kstlib/resilience/exceptions.py +95 -0
  120. kstlib/resilience/heartbeat.py +491 -0
  121. kstlib/resilience/rate_limiter.py +506 -0
  122. kstlib/resilience/shutdown.py +417 -0
  123. kstlib/resilience/watchdog.py +637 -0
  124. kstlib/secrets/__init__.py +29 -0
  125. kstlib/secrets/exceptions.py +19 -0
  126. kstlib/secrets/models.py +62 -0
  127. kstlib/secrets/providers/__init__.py +79 -0
  128. kstlib/secrets/providers/base.py +58 -0
  129. kstlib/secrets/providers/environment.py +66 -0
  130. kstlib/secrets/providers/keyring.py +107 -0
  131. kstlib/secrets/providers/kms.py +223 -0
  132. kstlib/secrets/providers/kwargs.py +101 -0
  133. kstlib/secrets/providers/sops.py +209 -0
  134. kstlib/secrets/resolver.py +221 -0
  135. kstlib/secrets/sensitive.py +130 -0
  136. kstlib/secure/__init__.py +23 -0
  137. kstlib/secure/fs.py +194 -0
  138. kstlib/secure/permissions.py +70 -0
  139. kstlib/ssl.py +347 -0
  140. kstlib/ui/__init__.py +23 -0
  141. kstlib/ui/exceptions.py +26 -0
  142. kstlib/ui/panels.py +484 -0
  143. kstlib/ui/spinner.py +864 -0
  144. kstlib/ui/tables.py +382 -0
  145. kstlib/utils/__init__.py +48 -0
  146. kstlib/utils/dict.py +36 -0
  147. kstlib/utils/formatting.py +338 -0
  148. kstlib/utils/http_trace.py +237 -0
  149. kstlib/utils/lazy.py +49 -0
  150. kstlib/utils/secure_delete.py +205 -0
  151. kstlib/utils/serialization.py +247 -0
  152. kstlib/utils/text.py +56 -0
  153. kstlib/utils/validators.py +124 -0
  154. kstlib/websocket/__init__.py +97 -0
  155. kstlib/websocket/exceptions.py +214 -0
  156. kstlib/websocket/manager.py +1102 -0
  157. kstlib/websocket/models.py +361 -0
  158. kstlib-1.0.0.dist-info/METADATA +201 -0
  159. kstlib-1.0.0.dist-info/RECORD +163 -0
  160. {kstlib-0.0.1a0.dist-info → kstlib-1.0.0.dist-info}/WHEEL +1 -1
  161. kstlib-1.0.0.dist-info/entry_points.txt +2 -0
  162. kstlib-1.0.0.dist-info/licenses/LICENSE.md +9 -0
  163. kstlib-0.0.1a0.dist-info/METADATA +0 -29
  164. kstlib-0.0.1a0.dist-info/RECORD +0 -6
  165. kstlib-0.0.1a0.dist-info/licenses/LICENSE.md +0 -5
  166. {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
+ ]