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,579 @@
|
|
|
1
|
+
"""Delivery backends for monitoring results.
|
|
2
|
+
|
|
3
|
+
This module provides delivery mechanisms for MonitoringResult outputs:
|
|
4
|
+
|
|
5
|
+
- **FileDelivery**: Save HTML to local files with rotation
|
|
6
|
+
- **MailDelivery**: Send via kstlib.mail transports (wrapper)
|
|
7
|
+
|
|
8
|
+
Examples:
|
|
9
|
+
Save to file:
|
|
10
|
+
|
|
11
|
+
>>> from kstlib.monitoring.delivery import FileDelivery
|
|
12
|
+
>>> delivery = FileDelivery(output_dir="./reports") # doctest: +SKIP
|
|
13
|
+
>>> result = await delivery.deliver(monitoring_result, "daily") # doctest: +SKIP
|
|
14
|
+
>>> print(result.path) # doctest: +SKIP
|
|
15
|
+
|
|
16
|
+
Send via email:
|
|
17
|
+
|
|
18
|
+
>>> from kstlib.monitoring.delivery import MailDelivery
|
|
19
|
+
>>> delivery = MailDelivery( # doctest: +SKIP
|
|
20
|
+
... transport=gmail_transport,
|
|
21
|
+
... sender="bot@example.com",
|
|
22
|
+
... recipients=["team@example.com"],
|
|
23
|
+
... )
|
|
24
|
+
>>> result = await delivery.deliver(monitoring_result, "Daily Report") # doctest: +SKIP
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import asyncio
|
|
30
|
+
import pathlib
|
|
31
|
+
import re
|
|
32
|
+
from abc import ABC, abstractmethod
|
|
33
|
+
from dataclasses import dataclass, field
|
|
34
|
+
from datetime import datetime, timezone
|
|
35
|
+
from email.message import EmailMessage
|
|
36
|
+
from typing import TYPE_CHECKING, Any
|
|
37
|
+
|
|
38
|
+
from kstlib.monitoring.exceptions import MonitoringError
|
|
39
|
+
|
|
40
|
+
if TYPE_CHECKING:
|
|
41
|
+
from kstlib.mail.transport import AsyncMailTransport, MailTransport
|
|
42
|
+
from kstlib.monitoring.service import MonitoringResult
|
|
43
|
+
|
|
44
|
+
# Deep defense: Security limits
|
|
45
|
+
MAX_OUTPUT_DIR_DEPTH = 10 # Maximum directory depth from cwd
|
|
46
|
+
MAX_FILENAME_LENGTH = 200 # Maximum filename length
|
|
47
|
+
MAX_FILES_PER_DIR = 1000 # Maximum files to keep in output directory
|
|
48
|
+
MAX_FILE_SIZE = 50 * 1024 * 1024 # 50 MB max output file size
|
|
49
|
+
MAX_RECIPIENTS = 50 # Maximum email recipients
|
|
50
|
+
MAX_SUBJECT_LENGTH = 200 # Maximum email subject length
|
|
51
|
+
|
|
52
|
+
# Filename validation pattern (alphanumeric, dash, underscore, dot)
|
|
53
|
+
SAFE_FILENAME_PATTERN = re.compile(r"^[a-zA-Z0-9_\-\.]+$")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class DeliveryError(MonitoringError):
|
|
57
|
+
"""Base exception for delivery errors."""
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class DeliveryConfigError(DeliveryError, ValueError):
|
|
61
|
+
"""Invalid delivery configuration."""
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class DeliveryIOError(DeliveryError, OSError):
|
|
65
|
+
"""I/O error during delivery."""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True, slots=True)
|
|
69
|
+
class DeliveryResult:
|
|
70
|
+
"""Result of a delivery operation.
|
|
71
|
+
|
|
72
|
+
Attributes:
|
|
73
|
+
success: Whether delivery succeeded.
|
|
74
|
+
timestamp: When delivery was attempted.
|
|
75
|
+
path: Output file path (for file delivery).
|
|
76
|
+
message_id: Email message ID (for mail delivery).
|
|
77
|
+
error: Error message if delivery failed.
|
|
78
|
+
metadata: Additional delivery metadata.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
success: bool
|
|
82
|
+
timestamp: datetime
|
|
83
|
+
path: pathlib.Path | None = None
|
|
84
|
+
message_id: str | None = None
|
|
85
|
+
error: str | None = None
|
|
86
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class DeliveryBackend(ABC):
|
|
90
|
+
"""Abstract base class for delivery backends."""
|
|
91
|
+
|
|
92
|
+
@abstractmethod
|
|
93
|
+
async def deliver(
|
|
94
|
+
self,
|
|
95
|
+
result: MonitoringResult,
|
|
96
|
+
name: str,
|
|
97
|
+
) -> DeliveryResult:
|
|
98
|
+
"""Deliver a monitoring result.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
result: The MonitoringResult to deliver.
|
|
102
|
+
name: Name/subject for this delivery.
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
DeliveryResult with success status and metadata.
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _validate_path_safety(path: pathlib.Path, base_dir: pathlib.Path) -> None:
|
|
110
|
+
"""Validate path is within allowed directory (deep defense)."""
|
|
111
|
+
try:
|
|
112
|
+
resolved = path.resolve()
|
|
113
|
+
base_resolved = base_dir.resolve()
|
|
114
|
+
resolved.relative_to(base_resolved)
|
|
115
|
+
except ValueError as e:
|
|
116
|
+
raise DeliveryConfigError(f"Path traversal detected: {path}") from e
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _sanitize_filename(name: str, timestamp: datetime) -> str:
|
|
120
|
+
"""Create a safe filename from name and timestamp."""
|
|
121
|
+
# Remove unsafe characters
|
|
122
|
+
safe_name = re.sub(r"[^a-zA-Z0-9_\-]", "_", name)
|
|
123
|
+
# Limit length
|
|
124
|
+
safe_name = safe_name[:50]
|
|
125
|
+
# Add timestamp
|
|
126
|
+
ts = timestamp.strftime("%Y%m%d_%H%M%S")
|
|
127
|
+
return f"{safe_name}_{ts}.html"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass
|
|
131
|
+
class FileDeliveryConfig:
|
|
132
|
+
"""Configuration for file delivery.
|
|
133
|
+
|
|
134
|
+
Attributes:
|
|
135
|
+
output_dir: Directory to save files.
|
|
136
|
+
filename_template: Template for filenames (supports {name}, {timestamp}).
|
|
137
|
+
create_dirs: Create output directory if missing.
|
|
138
|
+
max_files: Maximum files to keep (oldest deleted, 0=unlimited).
|
|
139
|
+
encoding: File encoding.
|
|
140
|
+
"""
|
|
141
|
+
|
|
142
|
+
output_dir: str | pathlib.Path
|
|
143
|
+
filename_template: str = "{name}_{timestamp}.html"
|
|
144
|
+
create_dirs: bool = True
|
|
145
|
+
max_files: int = 100
|
|
146
|
+
encoding: str = "utf-8"
|
|
147
|
+
|
|
148
|
+
def __post_init__(self) -> None:
|
|
149
|
+
"""Validate configuration after initialization."""
|
|
150
|
+
if isinstance(self.output_dir, str):
|
|
151
|
+
object.__setattr__(self, "output_dir", pathlib.Path(self.output_dir))
|
|
152
|
+
|
|
153
|
+
# Deep defense: Validate max_files
|
|
154
|
+
if self.max_files < 0:
|
|
155
|
+
raise DeliveryConfigError("max_files cannot be negative")
|
|
156
|
+
if self.max_files > MAX_FILES_PER_DIR:
|
|
157
|
+
raise DeliveryConfigError(f"max_files exceeds limit ({MAX_FILES_PER_DIR})")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
class FileDelivery(DeliveryBackend):
|
|
161
|
+
"""Deliver monitoring results to local files.
|
|
162
|
+
|
|
163
|
+
Saves HTML output to files with automatic rotation and cleanup.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
output_dir: Directory to save files (str or Path).
|
|
167
|
+
filename_template: Template for filenames.
|
|
168
|
+
create_dirs: Create output directory if missing.
|
|
169
|
+
max_files: Maximum files to keep (oldest deleted when exceeded).
|
|
170
|
+
encoding: File encoding.
|
|
171
|
+
|
|
172
|
+
Examples:
|
|
173
|
+
>>> delivery = FileDelivery(output_dir="./reports") # doctest: +SKIP
|
|
174
|
+
>>> result = await delivery.deliver(monitoring_result, "daily") # doctest: +SKIP
|
|
175
|
+
>>> print(f"Saved to: {result.path}") # doctest: +SKIP
|
|
176
|
+
|
|
177
|
+
With rotation (keep last 7 files):
|
|
178
|
+
|
|
179
|
+
>>> delivery = FileDelivery( # doctest: +SKIP
|
|
180
|
+
... output_dir="./reports",
|
|
181
|
+
... max_files=7,
|
|
182
|
+
... )
|
|
183
|
+
"""
|
|
184
|
+
|
|
185
|
+
def __init__(
|
|
186
|
+
self,
|
|
187
|
+
output_dir: str | pathlib.Path,
|
|
188
|
+
*,
|
|
189
|
+
filename_template: str = "{name}_{timestamp}.html",
|
|
190
|
+
create_dirs: bool = True,
|
|
191
|
+
max_files: int = 100,
|
|
192
|
+
encoding: str = "utf-8",
|
|
193
|
+
) -> None:
|
|
194
|
+
"""Initialize file delivery backend."""
|
|
195
|
+
self._config = FileDeliveryConfig(
|
|
196
|
+
output_dir=pathlib.Path(output_dir),
|
|
197
|
+
filename_template=filename_template,
|
|
198
|
+
create_dirs=create_dirs,
|
|
199
|
+
max_files=max_files,
|
|
200
|
+
encoding=encoding,
|
|
201
|
+
)
|
|
202
|
+
self._last_result: DeliveryResult | None = None
|
|
203
|
+
|
|
204
|
+
@property
|
|
205
|
+
def config(self) -> FileDeliveryConfig:
|
|
206
|
+
"""Return the delivery configuration."""
|
|
207
|
+
return self._config
|
|
208
|
+
|
|
209
|
+
@property
|
|
210
|
+
def last_result(self) -> DeliveryResult | None:
|
|
211
|
+
"""Return the last delivery result."""
|
|
212
|
+
return self._last_result
|
|
213
|
+
|
|
214
|
+
def _generate_filename(self, name: str, timestamp: datetime) -> str:
|
|
215
|
+
"""Generate filename from template."""
|
|
216
|
+
ts_str = timestamp.strftime("%Y%m%d_%H%M%S")
|
|
217
|
+
# Sanitize name
|
|
218
|
+
safe_name = re.sub(r"[^a-zA-Z0-9_\-]", "_", name)[:50]
|
|
219
|
+
filename = self._config.filename_template.format(
|
|
220
|
+
name=safe_name,
|
|
221
|
+
timestamp=ts_str,
|
|
222
|
+
)
|
|
223
|
+
# Deep defense: Validate final filename
|
|
224
|
+
if len(filename) > MAX_FILENAME_LENGTH:
|
|
225
|
+
raise DeliveryConfigError(f"Generated filename too long ({len(filename)} > {MAX_FILENAME_LENGTH})")
|
|
226
|
+
return filename
|
|
227
|
+
|
|
228
|
+
def _cleanup_old_files(self, output_dir: pathlib.Path) -> int:
|
|
229
|
+
"""Remove oldest files if max_files exceeded. Returns count deleted."""
|
|
230
|
+
if self._config.max_files == 0:
|
|
231
|
+
return 0
|
|
232
|
+
|
|
233
|
+
html_files = sorted(
|
|
234
|
+
output_dir.glob("*.html"),
|
|
235
|
+
key=lambda p: p.stat().st_mtime,
|
|
236
|
+
)
|
|
237
|
+
to_delete = len(html_files) - self._config.max_files
|
|
238
|
+
deleted = 0
|
|
239
|
+
|
|
240
|
+
if to_delete > 0:
|
|
241
|
+
for old_file in html_files[:to_delete]:
|
|
242
|
+
try:
|
|
243
|
+
old_file.unlink()
|
|
244
|
+
deleted += 1
|
|
245
|
+
except OSError:
|
|
246
|
+
pass # Best effort cleanup
|
|
247
|
+
|
|
248
|
+
return deleted
|
|
249
|
+
|
|
250
|
+
def _validate_output_dir(self, output_dir: pathlib.Path) -> None:
|
|
251
|
+
"""Validate output directory depth and existence (deep defense)."""
|
|
252
|
+
try:
|
|
253
|
+
cwd = pathlib.Path.cwd().resolve()
|
|
254
|
+
rel_path = output_dir.relative_to(cwd)
|
|
255
|
+
if len(rel_path.parts) > MAX_OUTPUT_DIR_DEPTH:
|
|
256
|
+
raise DeliveryConfigError(f"Output directory too deep ({len(rel_path.parts)} > {MAX_OUTPUT_DIR_DEPTH})")
|
|
257
|
+
except ValueError:
|
|
258
|
+
# Path not relative to cwd, check absolute depth
|
|
259
|
+
if len(output_dir.parts) > MAX_OUTPUT_DIR_DEPTH + 5:
|
|
260
|
+
raise DeliveryConfigError("Output directory path too deep") from None
|
|
261
|
+
|
|
262
|
+
# Create directory if needed
|
|
263
|
+
if self._config.create_dirs:
|
|
264
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
265
|
+
|
|
266
|
+
if not output_dir.is_dir():
|
|
267
|
+
raise DeliveryConfigError(f"Output directory does not exist: {output_dir}")
|
|
268
|
+
|
|
269
|
+
async def deliver(
|
|
270
|
+
self,
|
|
271
|
+
result: MonitoringResult,
|
|
272
|
+
name: str,
|
|
273
|
+
) -> DeliveryResult:
|
|
274
|
+
"""Save monitoring result HTML to a file.
|
|
275
|
+
|
|
276
|
+
Args:
|
|
277
|
+
result: The MonitoringResult to save.
|
|
278
|
+
name: Name for this report (used in filename).
|
|
279
|
+
|
|
280
|
+
Returns:
|
|
281
|
+
DeliveryResult with file path on success.
|
|
282
|
+
|
|
283
|
+
Raises:
|
|
284
|
+
DeliveryIOError: If file cannot be written.
|
|
285
|
+
DeliveryConfigError: If configuration is invalid.
|
|
286
|
+
"""
|
|
287
|
+
timestamp = datetime.now(timezone.utc)
|
|
288
|
+
delivery_result: DeliveryResult | None = None
|
|
289
|
+
|
|
290
|
+
try:
|
|
291
|
+
output_dir = pathlib.Path(self._config.output_dir).resolve()
|
|
292
|
+
|
|
293
|
+
# Deep defense: Validate directory
|
|
294
|
+
self._validate_output_dir(output_dir)
|
|
295
|
+
|
|
296
|
+
# Generate filename
|
|
297
|
+
filename = self._generate_filename(name, timestamp)
|
|
298
|
+
output_path = output_dir / filename
|
|
299
|
+
|
|
300
|
+
# Deep defense: Validate path safety
|
|
301
|
+
_validate_path_safety(output_path, output_dir)
|
|
302
|
+
|
|
303
|
+
# Deep defense: Check content size
|
|
304
|
+
html_bytes = result.html.encode(self._config.encoding)
|
|
305
|
+
if len(html_bytes) > MAX_FILE_SIZE:
|
|
306
|
+
raise DeliveryConfigError(f"Output too large ({len(html_bytes)} > {MAX_FILE_SIZE} bytes)")
|
|
307
|
+
|
|
308
|
+
# Write file (run in executor for async compatibility)
|
|
309
|
+
loop = asyncio.get_running_loop()
|
|
310
|
+
await loop.run_in_executor(
|
|
311
|
+
None,
|
|
312
|
+
lambda: output_path.write_bytes(html_bytes),
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
# Cleanup old files
|
|
316
|
+
deleted = await loop.run_in_executor(
|
|
317
|
+
None,
|
|
318
|
+
lambda: self._cleanup_old_files(output_dir),
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
delivery_result = DeliveryResult(
|
|
322
|
+
success=True,
|
|
323
|
+
timestamp=timestamp,
|
|
324
|
+
path=output_path,
|
|
325
|
+
metadata={
|
|
326
|
+
"size_bytes": len(html_bytes),
|
|
327
|
+
"files_deleted": deleted,
|
|
328
|
+
"encoding": self._config.encoding,
|
|
329
|
+
},
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
except DeliveryError as e:
|
|
333
|
+
delivery_result = DeliveryResult(
|
|
334
|
+
success=False,
|
|
335
|
+
timestamp=timestamp,
|
|
336
|
+
error=str(e),
|
|
337
|
+
)
|
|
338
|
+
raise
|
|
339
|
+
except OSError as e:
|
|
340
|
+
delivery_result = DeliveryResult(
|
|
341
|
+
success=False,
|
|
342
|
+
timestamp=timestamp,
|
|
343
|
+
error=f"I/O error: {e}",
|
|
344
|
+
)
|
|
345
|
+
raise DeliveryIOError(f"Failed to write file: {e}") from e
|
|
346
|
+
except Exception as e:
|
|
347
|
+
delivery_result = DeliveryResult(
|
|
348
|
+
success=False,
|
|
349
|
+
timestamp=timestamp,
|
|
350
|
+
error=str(e),
|
|
351
|
+
)
|
|
352
|
+
raise DeliveryError(f"Unexpected error during delivery: {e}") from e
|
|
353
|
+
finally:
|
|
354
|
+
if delivery_result is not None:
|
|
355
|
+
self._last_result = delivery_result
|
|
356
|
+
|
|
357
|
+
return delivery_result
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
@dataclass
|
|
361
|
+
class MailDeliveryConfig:
|
|
362
|
+
"""Configuration for mail delivery.
|
|
363
|
+
|
|
364
|
+
Attributes:
|
|
365
|
+
sender: Sender email address.
|
|
366
|
+
recipients: List of recipient addresses.
|
|
367
|
+
cc: List of CC addresses.
|
|
368
|
+
bcc: List of BCC addresses.
|
|
369
|
+
subject_template: Subject template (supports {name}).
|
|
370
|
+
include_plain_text: Include plain text version.
|
|
371
|
+
"""
|
|
372
|
+
|
|
373
|
+
sender: str
|
|
374
|
+
recipients: list[str]
|
|
375
|
+
cc: list[str] = field(default_factory=list)
|
|
376
|
+
bcc: list[str] = field(default_factory=list)
|
|
377
|
+
subject_template: str = "Monitoring Report: {name}"
|
|
378
|
+
include_plain_text: bool = True
|
|
379
|
+
|
|
380
|
+
def __post_init__(self) -> None:
|
|
381
|
+
"""Validate configuration after initialization."""
|
|
382
|
+
if not self.sender:
|
|
383
|
+
raise DeliveryConfigError("Sender address is required")
|
|
384
|
+
if not self.recipients:
|
|
385
|
+
raise DeliveryConfigError("At least one recipient is required")
|
|
386
|
+
|
|
387
|
+
# Deep defense: Limit recipients
|
|
388
|
+
total_recipients = len(self.recipients) + len(self.cc) + len(self.bcc)
|
|
389
|
+
if total_recipients > MAX_RECIPIENTS:
|
|
390
|
+
raise DeliveryConfigError(f"Too many recipients ({total_recipients} > {MAX_RECIPIENTS})")
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
class MailDelivery(DeliveryBackend):
|
|
394
|
+
"""Deliver monitoring results via email.
|
|
395
|
+
|
|
396
|
+
Wraps kstlib.mail transports for monitoring delivery.
|
|
397
|
+
|
|
398
|
+
Args:
|
|
399
|
+
transport: Mail transport (sync or async).
|
|
400
|
+
sender: Sender email address.
|
|
401
|
+
recipients: List of recipient addresses.
|
|
402
|
+
cc: Optional CC addresses.
|
|
403
|
+
bcc: Optional BCC addresses.
|
|
404
|
+
subject_template: Subject template with {name} placeholder.
|
|
405
|
+
include_plain_text: Include plain text version of HTML.
|
|
406
|
+
|
|
407
|
+
Examples:
|
|
408
|
+
>>> from kstlib.mail.transports.gmail import GmailTransport
|
|
409
|
+
>>> transport = GmailTransport(...) # doctest: +SKIP
|
|
410
|
+
>>> delivery = MailDelivery( # doctest: +SKIP
|
|
411
|
+
... transport=transport,
|
|
412
|
+
... sender="bot@example.com",
|
|
413
|
+
... recipients=["team@example.com"],
|
|
414
|
+
... )
|
|
415
|
+
>>> result = await delivery.deliver(monitoring_result, "Daily Report") # doctest: +SKIP
|
|
416
|
+
"""
|
|
417
|
+
|
|
418
|
+
def __init__(
|
|
419
|
+
self,
|
|
420
|
+
transport: MailTransport | AsyncMailTransport,
|
|
421
|
+
config: MailDeliveryConfig,
|
|
422
|
+
) -> None:
|
|
423
|
+
"""Initialize mail delivery backend.
|
|
424
|
+
|
|
425
|
+
Args:
|
|
426
|
+
transport: Mail transport (sync or async).
|
|
427
|
+
config: Mail delivery configuration.
|
|
428
|
+
"""
|
|
429
|
+
self._transport = transport
|
|
430
|
+
self._config = config
|
|
431
|
+
self._last_result: DeliveryResult | None = None
|
|
432
|
+
|
|
433
|
+
@classmethod
|
|
434
|
+
def create(
|
|
435
|
+
cls,
|
|
436
|
+
transport: MailTransport | AsyncMailTransport,
|
|
437
|
+
sender: str,
|
|
438
|
+
recipients: list[str],
|
|
439
|
+
**kwargs: Any,
|
|
440
|
+
) -> MailDelivery:
|
|
441
|
+
"""Create a MailDelivery with configuration.
|
|
442
|
+
|
|
443
|
+
Convenience factory method that creates the config internally.
|
|
444
|
+
|
|
445
|
+
Args:
|
|
446
|
+
transport: Mail transport (sync or async).
|
|
447
|
+
sender: Sender email address.
|
|
448
|
+
recipients: List of recipient addresses.
|
|
449
|
+
**kwargs: Additional config options (cc, bcc, subject_template, etc.).
|
|
450
|
+
|
|
451
|
+
Returns:
|
|
452
|
+
Configured MailDelivery instance.
|
|
453
|
+
"""
|
|
454
|
+
config = MailDeliveryConfig(
|
|
455
|
+
sender=sender,
|
|
456
|
+
recipients=list(recipients),
|
|
457
|
+
cc=list(kwargs.get("cc", [])) if kwargs.get("cc") else [],
|
|
458
|
+
bcc=list(kwargs.get("bcc", [])) if kwargs.get("bcc") else [],
|
|
459
|
+
subject_template=kwargs.get("subject_template", "Monitoring Report: {name}"),
|
|
460
|
+
include_plain_text=kwargs.get("include_plain_text", True),
|
|
461
|
+
)
|
|
462
|
+
return cls(transport, config)
|
|
463
|
+
|
|
464
|
+
@property
|
|
465
|
+
def config(self) -> MailDeliveryConfig:
|
|
466
|
+
"""Return the delivery configuration."""
|
|
467
|
+
return self._config
|
|
468
|
+
|
|
469
|
+
@property
|
|
470
|
+
def last_result(self) -> DeliveryResult | None:
|
|
471
|
+
"""Return the last delivery result."""
|
|
472
|
+
return self._last_result
|
|
473
|
+
|
|
474
|
+
def _build_message(self, html: str, subject: str) -> EmailMessage:
|
|
475
|
+
"""Build EmailMessage from HTML content."""
|
|
476
|
+
msg = EmailMessage()
|
|
477
|
+
msg["From"] = self._config.sender
|
|
478
|
+
msg["To"] = ", ".join(self._config.recipients)
|
|
479
|
+
if self._config.cc:
|
|
480
|
+
msg["Cc"] = ", ".join(self._config.cc)
|
|
481
|
+
if self._config.bcc:
|
|
482
|
+
msg["Bcc"] = ", ".join(self._config.bcc)
|
|
483
|
+
|
|
484
|
+
# Deep defense: Validate subject length
|
|
485
|
+
if len(subject) > MAX_SUBJECT_LENGTH:
|
|
486
|
+
subject = subject[: MAX_SUBJECT_LENGTH - 3] + "..."
|
|
487
|
+
|
|
488
|
+
msg["Subject"] = subject
|
|
489
|
+
|
|
490
|
+
if self._config.include_plain_text:
|
|
491
|
+
# Create multipart message with plain and HTML
|
|
492
|
+
# Simple HTML to text conversion (strip tags)
|
|
493
|
+
plain_text = re.sub(r"<[^>]+>", "", html)
|
|
494
|
+
plain_text = re.sub(r"\s+", " ", plain_text).strip()
|
|
495
|
+
|
|
496
|
+
msg.set_content(plain_text)
|
|
497
|
+
msg.add_alternative(html, subtype="html")
|
|
498
|
+
else:
|
|
499
|
+
msg.set_content(html, subtype="html")
|
|
500
|
+
|
|
501
|
+
return msg
|
|
502
|
+
|
|
503
|
+
async def deliver(
|
|
504
|
+
self,
|
|
505
|
+
result: MonitoringResult,
|
|
506
|
+
name: str,
|
|
507
|
+
) -> DeliveryResult:
|
|
508
|
+
"""Send monitoring result via email.
|
|
509
|
+
|
|
510
|
+
Args:
|
|
511
|
+
result: The MonitoringResult to send.
|
|
512
|
+
name: Name for this report (used in subject).
|
|
513
|
+
|
|
514
|
+
Returns:
|
|
515
|
+
DeliveryResult with message ID on success.
|
|
516
|
+
|
|
517
|
+
Raises:
|
|
518
|
+
DeliveryError: If email cannot be sent.
|
|
519
|
+
"""
|
|
520
|
+
import inspect
|
|
521
|
+
|
|
522
|
+
timestamp = datetime.now(timezone.utc)
|
|
523
|
+
|
|
524
|
+
try:
|
|
525
|
+
# Generate subject
|
|
526
|
+
subject = self._config.subject_template.format(name=name)
|
|
527
|
+
|
|
528
|
+
# Build message
|
|
529
|
+
message = self._build_message(result.html, subject)
|
|
530
|
+
|
|
531
|
+
# Send via transport
|
|
532
|
+
if hasattr(self._transport, "send") and inspect.iscoroutinefunction(self._transport.send):
|
|
533
|
+
await self._transport.send(message)
|
|
534
|
+
else:
|
|
535
|
+
# Sync transport - run in executor
|
|
536
|
+
loop = asyncio.get_running_loop()
|
|
537
|
+
await loop.run_in_executor(None, self._transport.send, message)
|
|
538
|
+
|
|
539
|
+
# Try to get message ID from transport response
|
|
540
|
+
message_id = None
|
|
541
|
+
if hasattr(self._transport, "last_response"):
|
|
542
|
+
resp = self._transport.last_response
|
|
543
|
+
if resp and hasattr(resp, "id"):
|
|
544
|
+
message_id = resp.id
|
|
545
|
+
|
|
546
|
+
delivery_result = DeliveryResult(
|
|
547
|
+
success=True,
|
|
548
|
+
timestamp=timestamp,
|
|
549
|
+
message_id=message_id,
|
|
550
|
+
metadata={
|
|
551
|
+
"recipients": len(self._config.recipients),
|
|
552
|
+
"subject": subject,
|
|
553
|
+
},
|
|
554
|
+
)
|
|
555
|
+
|
|
556
|
+
except Exception as e:
|
|
557
|
+
delivery_result = DeliveryResult(
|
|
558
|
+
success=False,
|
|
559
|
+
timestamp=timestamp,
|
|
560
|
+
error=str(e),
|
|
561
|
+
)
|
|
562
|
+
raise DeliveryError(f"Failed to send email: {e}") from e
|
|
563
|
+
finally:
|
|
564
|
+
self._last_result = delivery_result
|
|
565
|
+
|
|
566
|
+
return delivery_result
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
__all__ = [
|
|
570
|
+
"DeliveryBackend",
|
|
571
|
+
"DeliveryConfigError",
|
|
572
|
+
"DeliveryError",
|
|
573
|
+
"DeliveryIOError",
|
|
574
|
+
"DeliveryResult",
|
|
575
|
+
"FileDelivery",
|
|
576
|
+
"FileDeliveryConfig",
|
|
577
|
+
"MailDelivery",
|
|
578
|
+
"MailDeliveryConfig",
|
|
579
|
+
]
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Specialized exceptions raised by the kstlib.monitoring module.
|
|
2
|
+
|
|
3
|
+
Exception hierarchy::
|
|
4
|
+
|
|
5
|
+
KstlibError
|
|
6
|
+
MonitoringError (base)
|
|
7
|
+
CollectorError
|
|
8
|
+
RenderError
|
|
9
|
+
MonitoringConfigError
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from kstlib.config.exceptions import KstlibError
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class MonitoringError(KstlibError):
|
|
18
|
+
"""Base exception for all monitoring errors."""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class CollectorError(MonitoringError):
|
|
22
|
+
"""Error during data collection.
|
|
23
|
+
|
|
24
|
+
Raised when a collector callable fails during MonitoringService.collect().
|
|
25
|
+
|
|
26
|
+
Attributes:
|
|
27
|
+
collector_name: Name of the failed collector.
|
|
28
|
+
cause: The underlying exception that caused the failure.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(self, collector_name: str, cause: Exception) -> None:
|
|
32
|
+
"""Initialize with collector name and underlying cause.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
collector_name: Name of the failed collector.
|
|
36
|
+
cause: The exception that caused the failure.
|
|
37
|
+
"""
|
|
38
|
+
self.collector_name = collector_name
|
|
39
|
+
self.cause = cause
|
|
40
|
+
super().__init__(f"Collector '{collector_name}' failed: {cause}")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class RenderError(MonitoringError, ValueError):
|
|
44
|
+
"""HTML rendering failed.
|
|
45
|
+
|
|
46
|
+
Raised when a renderable object cannot produce valid HTML output,
|
|
47
|
+
for example due to inconsistent data dimensions or template errors.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class MonitoringConfigError(MonitoringError):
|
|
52
|
+
"""Base exception for monitoring configuration errors.
|
|
53
|
+
|
|
54
|
+
Raised when a monitoring configuration file cannot be loaded or parsed.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
__all__ = [
|
|
59
|
+
"CollectorError",
|
|
60
|
+
"MonitoringConfigError",
|
|
61
|
+
"MonitoringError",
|
|
62
|
+
"RenderError",
|
|
63
|
+
]
|