messagefoundry 0.1.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.
- messagefoundry/__init__.py +108 -0
- messagefoundry/__main__.py +1155 -0
- messagefoundry/api/__init__.py +27 -0
- messagefoundry/api/app.py +1581 -0
- messagefoundry/api/approvals.py +184 -0
- messagefoundry/api/auth_models.py +211 -0
- messagefoundry/api/auth_routes.py +655 -0
- messagefoundry/api/field_authz.py +96 -0
- messagefoundry/api/models.py +374 -0
- messagefoundry/api/security.py +247 -0
- messagefoundry/api/tls.py +47 -0
- messagefoundry/auth/__init__.py +39 -0
- messagefoundry/auth/data/common_passwords.NOTICE +13 -0
- messagefoundry/auth/data/common_passwords.txt +10000 -0
- messagefoundry/auth/identity.py +71 -0
- messagefoundry/auth/ldap.py +264 -0
- messagefoundry/auth/notifications.py +68 -0
- messagefoundry/auth/passwords.py +53 -0
- messagefoundry/auth/permissions.py +120 -0
- messagefoundry/auth/policy.py +153 -0
- messagefoundry/auth/ratelimit.py +55 -0
- messagefoundry/auth/service.py +1323 -0
- messagefoundry/auth/tokens.py +26 -0
- messagefoundry/auth/totp.py +174 -0
- messagefoundry/checks.py +174 -0
- messagefoundry/config/__init__.py +30 -0
- messagefoundry/config/active_environment.py +80 -0
- messagefoundry/config/ai_policy.py +140 -0
- messagefoundry/config/code_sets.py +260 -0
- messagefoundry/config/connections_edit.py +200 -0
- messagefoundry/config/connections_file.py +287 -0
- messagefoundry/config/db_lookup.py +117 -0
- messagefoundry/config/environments.py +116 -0
- messagefoundry/config/ingest_time.py +83 -0
- messagefoundry/config/models.py +240 -0
- messagefoundry/config/reference.py +158 -0
- messagefoundry/config/response.py +83 -0
- messagefoundry/config/run_context.py +153 -0
- messagefoundry/config/settings.py +1311 -0
- messagefoundry/config/state.py +99 -0
- messagefoundry/config/tls_policy.py +110 -0
- messagefoundry/config/wiring.py +1918 -0
- messagefoundry/console/__init__.py +20 -0
- messagefoundry/console/__main__.py +274 -0
- messagefoundry/console/_async.py +107 -0
- messagefoundry/console/change_password.py +111 -0
- messagefoundry/console/client.py +552 -0
- messagefoundry/console/connections.py +324 -0
- messagefoundry/console/login.py +107 -0
- messagefoundry/console/mfa.py +205 -0
- messagefoundry/console/reauth.py +94 -0
- messagefoundry/console/search.py +57 -0
- messagefoundry/console/service_control.py +137 -0
- messagefoundry/console/sessions.py +122 -0
- messagefoundry/console/shell.py +410 -0
- messagefoundry/console/status.py +377 -0
- messagefoundry/console/users_page.py +282 -0
- messagefoundry/console/widgets.py +553 -0
- messagefoundry/generators/README.md +27 -0
- messagefoundry/generators/__init__.py +15 -0
- messagefoundry/generators/_core.py +589 -0
- messagefoundry/generators/_hl7data.py +428 -0
- messagefoundry/generators/adt.py +286 -0
- messagefoundry/generators/all_types.py +24 -0
- messagefoundry/generators/bar.py +28 -0
- messagefoundry/generators/dft.py +20 -0
- messagefoundry/generators/mdm.py +39 -0
- messagefoundry/generators/mfn.py +46 -0
- messagefoundry/generators/oml.py +32 -0
- messagefoundry/generators/orl.py +30 -0
- messagefoundry/generators/orm.py +23 -0
- messagefoundry/generators/oru.py +21 -0
- messagefoundry/generators/ras.py +20 -0
- messagefoundry/generators/rde.py +54 -0
- messagefoundry/generators/siu.py +64 -0
- messagefoundry/generators/vxu.py +20 -0
- messagefoundry/hl7schema.py +75 -0
- messagefoundry/last_resort.py +55 -0
- messagefoundry/logging_setup.py +332 -0
- messagefoundry/parsing/__init__.py +64 -0
- messagefoundry/parsing/consistency.py +166 -0
- messagefoundry/parsing/groups.py +228 -0
- messagefoundry/parsing/message.py +453 -0
- messagefoundry/parsing/peek.py +237 -0
- messagefoundry/parsing/split.py +120 -0
- messagefoundry/parsing/summary.py +46 -0
- messagefoundry/parsing/tree.py +128 -0
- messagefoundry/parsing/validate.py +95 -0
- messagefoundry/parsing/x12/__init__.py +46 -0
- messagefoundry/parsing/x12/delimiters.py +140 -0
- messagefoundry/parsing/x12/errors.py +30 -0
- messagefoundry/parsing/x12/interchange.py +232 -0
- messagefoundry/parsing/x12/message.py +200 -0
- messagefoundry/parsing/x12/peek.py +207 -0
- messagefoundry/pipeline/__init__.py +21 -0
- messagefoundry/pipeline/alert_sinks.py +486 -0
- messagefoundry/pipeline/alerts.py +100 -0
- messagefoundry/pipeline/cert_expiry.py +219 -0
- messagefoundry/pipeline/cluster.py +955 -0
- messagefoundry/pipeline/cluster_sqlserver.py +444 -0
- messagefoundry/pipeline/config_convergence.py +137 -0
- messagefoundry/pipeline/dryrun.py +450 -0
- messagefoundry/pipeline/engine.py +756 -0
- messagefoundry/pipeline/leader_tasks.py +158 -0
- messagefoundry/pipeline/reference_sync.py +369 -0
- messagefoundry/pipeline/retention.py +289 -0
- messagefoundry/pipeline/security_notify.py +168 -0
- messagefoundry/pipeline/state_convergence.py +143 -0
- messagefoundry/pipeline/wiring_runner.py +1722 -0
- messagefoundry/py.typed +0 -0
- messagefoundry/redaction.py +71 -0
- messagefoundry/scaffold.py +321 -0
- messagefoundry/secrets_dpapi.py +129 -0
- messagefoundry/store/__init__.py +46 -0
- messagefoundry/store/audit_tee.py +67 -0
- messagefoundry/store/base.py +758 -0
- messagefoundry/store/crypto.py +166 -0
- messagefoundry/store/keyprovider.py +192 -0
- messagefoundry/store/postgres.py +3447 -0
- messagefoundry/store/sqlserver.py +3014 -0
- messagefoundry/store/store.py +3790 -0
- messagefoundry/timezone.py +207 -0
- messagefoundry/transports/__init__.py +50 -0
- messagefoundry/transports/base.py +269 -0
- messagefoundry/transports/database.py +693 -0
- messagefoundry/transports/file.py +551 -0
- messagefoundry/transports/framing.py +164 -0
- messagefoundry/transports/loopback.py +53 -0
- messagefoundry/transports/mllp.py +644 -0
- messagefoundry/transports/remotefile.py +664 -0
- messagefoundry/transports/rest.py +281 -0
- messagefoundry/transports/signing.py +321 -0
- messagefoundry/transports/soap.py +507 -0
- messagefoundry/transports/tcp.py +307 -0
- messagefoundry/transports/timer.py +146 -0
- messagefoundry/transports/x12.py +323 -0
- messagefoundry-0.1.0.dist-info/METADATA +212 -0
- messagefoundry-0.1.0.dist-info/RECORD +142 -0
- messagefoundry-0.1.0.dist-info/WHEEL +4 -0
- messagefoundry-0.1.0.dist-info/entry_points.txt +2 -0
- messagefoundry-0.1.0.dist-info/licenses/LICENSE +662 -0
- messagefoundry-0.1.0.dist-info/licenses/NOTICE +27 -0
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 MessageFoundry Organization and contributors
|
|
3
|
+
"""Real :class:`~messagefoundry.pipeline.alerts.AlertSink` transports — webhook + email.
|
|
4
|
+
|
|
5
|
+
The :class:`AlertSink` emit methods are called **inline on a delivery worker** and must be cheap and
|
|
6
|
+
non-blocking (the lane is already stalled when we're alerting — the notification must never block or
|
|
7
|
+
hang it). So :class:`NotifierAlertSink` does no network I/O inline: each event is built and dropped on
|
|
8
|
+
a bounded in-memory queue, and a **background task** drains the queue and performs the actual sends
|
|
9
|
+
(webhook POST / SMTP) off the event loop via :func:`asyncio.to_thread`. A send failure is logged and
|
|
10
|
+
the other transports still run — alerting is best-effort and never breaks delivery.
|
|
11
|
+
|
|
12
|
+
Transports use only the **standard library** (``urllib.request`` / ``smtplib``), so wiring real
|
|
13
|
+
notifications adds no dependency. Payloads carry the connection name + queue shape only — **no PHI**.
|
|
14
|
+
|
|
15
|
+
Construction is config-driven: :func:`notifier_from_settings` turns an
|
|
16
|
+
:class:`~messagefoundry.config.settings.AlertsSettings` into a :class:`NotifierAlertSink`, or ``None``
|
|
17
|
+
when neither transport is configured (the engine then falls back to the logging sink).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import abc
|
|
23
|
+
import asyncio
|
|
24
|
+
import fnmatch
|
|
25
|
+
import json
|
|
26
|
+
import logging
|
|
27
|
+
import smtplib
|
|
28
|
+
import time
|
|
29
|
+
import urllib.parse
|
|
30
|
+
import urllib.request
|
|
31
|
+
from collections.abc import Mapping, Sequence
|
|
32
|
+
from dataclasses import dataclass
|
|
33
|
+
from email.message import EmailMessage
|
|
34
|
+
from typing import Any, Generic, Protocol, TypeVar
|
|
35
|
+
|
|
36
|
+
from messagefoundry.config.settings import (
|
|
37
|
+
INSECURE_TLS_ESCAPE_ENV,
|
|
38
|
+
AlertRule,
|
|
39
|
+
AlertSeverity,
|
|
40
|
+
AlertsSettings,
|
|
41
|
+
insecure_tls_allowed,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
__all__ = [
|
|
45
|
+
"AlertTransport",
|
|
46
|
+
"WebhookTransport",
|
|
47
|
+
"EmailTransport",
|
|
48
|
+
"AlertRuleSet",
|
|
49
|
+
"NotifierAlertSink",
|
|
50
|
+
"notifier_from_settings",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
log = logging.getLogger(__name__)
|
|
54
|
+
|
|
55
|
+
# Bound the in-memory backlog so a wedged transport (unreachable webhook) can't grow without limit;
|
|
56
|
+
# excess events are dropped with a warning rather than stalling the worker that enqueues them.
|
|
57
|
+
_MAX_QUEUE = 1000
|
|
58
|
+
|
|
59
|
+
_T = TypeVar("_T")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class _BackgroundDispatcher(abc.ABC, Generic[_T]):
|
|
63
|
+
"""A bounded in-memory queue drained by a single background task — the shared lifecycle behind
|
|
64
|
+
:class:`NotifierAlertSink` (operator alerts) and the per-user security-event notifier. The queue
|
|
65
|
+
caps the backlog so a wedged downstream can't grow memory without bound; excess items are dropped
|
|
66
|
+
with a warning (see :meth:`_enqueue`).
|
|
67
|
+
|
|
68
|
+
Subclasses implement :meth:`_handle` for the per-item work. It runs inside the background task,
|
|
69
|
+
must push any blocking I/O off the loop (``asyncio.to_thread``), and must be **best-effort** — it
|
|
70
|
+
swallows its own errors and never raises, since an exception out of ``_handle`` would kill the
|
|
71
|
+
drain loop. Lifecycle: :meth:`start` once an event loop is running (the ASGI lifespan does this),
|
|
72
|
+
:meth:`aclose` on shutdown (enqueues a ``None`` sentinel, drains what's queued, then stops)."""
|
|
73
|
+
|
|
74
|
+
def __init__(self, *, max_queue: int = _MAX_QUEUE) -> None:
|
|
75
|
+
self._queue: asyncio.Queue[_T | None] = asyncio.Queue(maxsize=max_queue)
|
|
76
|
+
self._task: asyncio.Task[None] | None = None
|
|
77
|
+
|
|
78
|
+
def _enqueue(self, item: _T, *, dropped: str) -> None:
|
|
79
|
+
"""Non-blocking enqueue; on a full queue, drop the item with a warning (``dropped`` names it)."""
|
|
80
|
+
try:
|
|
81
|
+
self._queue.put_nowait(item)
|
|
82
|
+
except asyncio.QueueFull:
|
|
83
|
+
log.warning("%s queue full; dropping %s", type(self).__name__, dropped)
|
|
84
|
+
|
|
85
|
+
def start(self) -> None:
|
|
86
|
+
if self._task is None:
|
|
87
|
+
self._task = asyncio.create_task(self._run())
|
|
88
|
+
|
|
89
|
+
async def aclose(self) -> None:
|
|
90
|
+
if self._task is None:
|
|
91
|
+
return
|
|
92
|
+
await self._queue.put(None) # sentinel: drain what's queued, then stop
|
|
93
|
+
try:
|
|
94
|
+
await self._task
|
|
95
|
+
finally:
|
|
96
|
+
self._task = None
|
|
97
|
+
|
|
98
|
+
async def _run(self) -> None:
|
|
99
|
+
while True:
|
|
100
|
+
item = await self._queue.get()
|
|
101
|
+
if item is None:
|
|
102
|
+
return
|
|
103
|
+
await self._handle(item)
|
|
104
|
+
|
|
105
|
+
@abc.abstractmethod
|
|
106
|
+
async def _handle(self, item: _T) -> None:
|
|
107
|
+
"""Process one queued item (best-effort, blocking I/O off-loop). Must not raise."""
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class AlertTransport(Protocol):
|
|
111
|
+
"""One delivery channel for an alert event. ``send`` does the actual (blocking) I/O — the
|
|
112
|
+
:class:`NotifierAlertSink` always calls it from a background task, never inline on a worker."""
|
|
113
|
+
|
|
114
|
+
name: str
|
|
115
|
+
|
|
116
|
+
async def send(self, event: dict[str, Any]) -> None: ...
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _subject(event: dict[str, Any]) -> str:
|
|
120
|
+
severity = str(event.get("severity", "warning")).upper()
|
|
121
|
+
return f"[MessageFoundry] {severity} {event['type']} — {event['connection']}"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _body(event: dict[str, Any]) -> str:
|
|
125
|
+
lines = [f"{k}: {v}" for k, v in event.items()]
|
|
126
|
+
return "\n".join(lines)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class _NoRedirectHandler(urllib.request.HTTPRedirectHandler):
|
|
130
|
+
"""Refuse to follow HTTP redirects on the outbound webhook POST (ASVS 15.3.2): a 3xx could
|
|
131
|
+
divert the alert to an unintended host or be chained to probe an internal endpoint. Returning
|
|
132
|
+
``None`` makes urllib raise on the redirect instead of following it, so the send fails (logged)
|
|
133
|
+
— the safe default for a fire-and-forget webhook."""
|
|
134
|
+
|
|
135
|
+
def redirect_request(
|
|
136
|
+
self,
|
|
137
|
+
req: urllib.request.Request,
|
|
138
|
+
fp: Any,
|
|
139
|
+
code: int,
|
|
140
|
+
msg: str,
|
|
141
|
+
headers: Any,
|
|
142
|
+
newurl: str,
|
|
143
|
+
) -> urllib.request.Request | None:
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
# A shared opener that never follows redirects; reused for every webhook POST.
|
|
148
|
+
_NO_REDIRECT_OPENER = urllib.request.build_opener(_NoRedirectHandler)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class WebhookTransport:
|
|
152
|
+
"""POST the event as JSON to a configured URL (fronts Slack/Teams/PagerDuty/custom webhooks)."""
|
|
153
|
+
|
|
154
|
+
def __init__(
|
|
155
|
+
self, url: str, *, timeout: float = 10.0, allowed_hosts: tuple[str, ...] = ()
|
|
156
|
+
) -> None:
|
|
157
|
+
# Refuse a plaintext http:// webhook target unless the explicit dev escape is set: the alert
|
|
158
|
+
# POST otherwise crosses the network in cleartext (ASVS 12.2.1 — no insecure fallback). https
|
|
159
|
+
# is the only scheme accepted by default; http(s) remain the only schemes at all (see _post).
|
|
160
|
+
# Same refuse-unless-MEFOR_ALLOW_INSECURE_TLS pattern as LDAPS / SQL Server / MLLP — stricter
|
|
161
|
+
# than the credentialed-only http refusal on REST/SOAP, since a webhook has no PHI but should
|
|
162
|
+
# still never fall back to cleartext.
|
|
163
|
+
scheme = urllib.parse.urlsplit(url).scheme.lower()
|
|
164
|
+
if scheme not in ("http", "https"):
|
|
165
|
+
raise ValueError(f"webhook url must be http or https, got scheme {scheme!r}")
|
|
166
|
+
if scheme == "http" and not insecure_tls_allowed():
|
|
167
|
+
raise ValueError(
|
|
168
|
+
f"webhook url {url!r} uses plaintext http; refused unless "
|
|
169
|
+
f"{INSECURE_TLS_ESCAPE_ENV} is set (dev/trusted-network only) — use https"
|
|
170
|
+
)
|
|
171
|
+
if scheme == "http":
|
|
172
|
+
log.warning(
|
|
173
|
+
"webhook target uses plaintext http; permitted only because %s is set "
|
|
174
|
+
"(cleartext, MITM-able — trusted-network/dev use only)",
|
|
175
|
+
INSECURE_TLS_ESCAPE_ENV,
|
|
176
|
+
)
|
|
177
|
+
self.url = url
|
|
178
|
+
self.timeout = timeout
|
|
179
|
+
# Optional egress allowlist (lower-cased); empty = any host. SSRF defense-in-depth (1.3.6).
|
|
180
|
+
self.allowed_hosts = tuple(h.lower() for h in allowed_hosts)
|
|
181
|
+
self.name = "webhook"
|
|
182
|
+
|
|
183
|
+
async def send(self, event: dict[str, Any]) -> None:
|
|
184
|
+
await asyncio.to_thread(self._post, event)
|
|
185
|
+
|
|
186
|
+
def _post(self, event: dict[str, Any]) -> None:
|
|
187
|
+
# Guard the scheme before opening: urllib would otherwise honour file:/ftp:/custom schemes
|
|
188
|
+
# (bandit B310). The URL is operator-configured, but an http(s)-only check is cheap
|
|
189
|
+
# defense-in-depth so a typo'd or hostile config can't turn an alert POST into a local-file
|
|
190
|
+
# read. With the scheme provably http/https here, the Request nosec is justified.
|
|
191
|
+
split = urllib.parse.urlsplit(self.url)
|
|
192
|
+
if split.scheme.lower() not in ("http", "https"):
|
|
193
|
+
raise ValueError(f"webhook url must be http or https, got scheme {split.scheme!r}")
|
|
194
|
+
host = (split.hostname or "").lower()
|
|
195
|
+
if self.allowed_hosts and host not in self.allowed_hosts:
|
|
196
|
+
raise ValueError(f"webhook host {host!r} is not in the configured allowlist")
|
|
197
|
+
data = json.dumps(event).encode("utf-8")
|
|
198
|
+
req = urllib.request.Request( # noqa: S310 # nosec B310 — scheme guarded to http(s) above
|
|
199
|
+
self.url,
|
|
200
|
+
data=data,
|
|
201
|
+
headers={"Content-Type": "application/json"},
|
|
202
|
+
method="POST",
|
|
203
|
+
)
|
|
204
|
+
# The no-redirect opener (not urllib.request.urlopen) so a 3xx can't divert the POST (15.3.2).
|
|
205
|
+
with _NO_REDIRECT_OPENER.open(req, timeout=self.timeout) as resp:
|
|
206
|
+
resp.read() # drain so the connection can be reused/closed cleanly
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def send_plain_email(
|
|
210
|
+
*,
|
|
211
|
+
host: str,
|
|
212
|
+
port: int,
|
|
213
|
+
sender: str,
|
|
214
|
+
recipients: list[str],
|
|
215
|
+
subject: str,
|
|
216
|
+
body: str,
|
|
217
|
+
use_tls: bool = True,
|
|
218
|
+
username: str | None = None,
|
|
219
|
+
password: str | None = None,
|
|
220
|
+
timeout: float = 30.0,
|
|
221
|
+
allowed_hosts: tuple[str, ...] = (),
|
|
222
|
+
) -> None:
|
|
223
|
+
"""Send one plain-text email via SMTP (STARTTLS by default). Blocking — call via
|
|
224
|
+
``asyncio.to_thread``. Shared by :class:`EmailTransport` (ops alerts) and the per-user
|
|
225
|
+
security-event notifier. An optional ``allowed_hosts`` egress allowlist gates the SMTP host."""
|
|
226
|
+
allowed = tuple(h.lower() for h in allowed_hosts)
|
|
227
|
+
if allowed and host.lower() not in allowed:
|
|
228
|
+
raise ValueError(f"SMTP host {host!r} is not in the configured allowlist")
|
|
229
|
+
msg = EmailMessage()
|
|
230
|
+
msg["Subject"] = subject
|
|
231
|
+
msg["From"] = sender
|
|
232
|
+
msg["To"] = ", ".join(recipients)
|
|
233
|
+
msg.set_content(body)
|
|
234
|
+
with smtplib.SMTP(host, port, timeout=timeout) as smtp:
|
|
235
|
+
if use_tls:
|
|
236
|
+
smtp.starttls()
|
|
237
|
+
if username is not None:
|
|
238
|
+
smtp.login(username, password or "")
|
|
239
|
+
smtp.send_message(msg)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
class EmailTransport:
|
|
243
|
+
"""Send the event as a short plain-text email via SMTP (STARTTLS by default)."""
|
|
244
|
+
|
|
245
|
+
def __init__(
|
|
246
|
+
self,
|
|
247
|
+
*,
|
|
248
|
+
host: str,
|
|
249
|
+
port: int,
|
|
250
|
+
sender: str,
|
|
251
|
+
recipients: list[str],
|
|
252
|
+
use_tls: bool = True,
|
|
253
|
+
username: str | None = None,
|
|
254
|
+
password: str | None = None,
|
|
255
|
+
timeout: float = 30.0,
|
|
256
|
+
allowed_hosts: tuple[str, ...] = (),
|
|
257
|
+
) -> None:
|
|
258
|
+
self.host = host
|
|
259
|
+
self.port = port
|
|
260
|
+
self.sender = sender
|
|
261
|
+
self.recipients = recipients
|
|
262
|
+
self.use_tls = use_tls
|
|
263
|
+
self.username = username
|
|
264
|
+
self.password = password
|
|
265
|
+
self.timeout = timeout
|
|
266
|
+
# Optional egress allowlist (lower-cased) for the SMTP host; empty = any (WP-11c, parity with
|
|
267
|
+
# the webhook allowlist). The alert payload carries no PHI, so this is general egress control.
|
|
268
|
+
self.allowed_hosts = tuple(h.lower() for h in allowed_hosts)
|
|
269
|
+
self.name = "email"
|
|
270
|
+
|
|
271
|
+
async def send(self, event: dict[str, Any]) -> None:
|
|
272
|
+
await asyncio.to_thread(self._send, event)
|
|
273
|
+
|
|
274
|
+
def _send(self, event: dict[str, Any]) -> None:
|
|
275
|
+
send_plain_email(
|
|
276
|
+
host=self.host,
|
|
277
|
+
port=self.port,
|
|
278
|
+
sender=self.sender,
|
|
279
|
+
recipients=self.recipients,
|
|
280
|
+
subject=_subject(event),
|
|
281
|
+
body=_body(event),
|
|
282
|
+
use_tls=self.use_tls,
|
|
283
|
+
username=self.username,
|
|
284
|
+
password=self.password,
|
|
285
|
+
timeout=self.timeout,
|
|
286
|
+
allowed_hosts=self.allowed_hosts,
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
@dataclass(frozen=True)
|
|
291
|
+
class _RuleDecision:
|
|
292
|
+
"""How to handle one event: its ``severity``, the transports to fire (``None`` = every configured
|
|
293
|
+
transport; ``()`` = suppress entirely), and the re-alert ``cooldown_seconds`` (``None`` = the
|
|
294
|
+
notifier's global ``realert_seconds``)."""
|
|
295
|
+
|
|
296
|
+
severity: str
|
|
297
|
+
transports: tuple[str, ...] | None
|
|
298
|
+
cooldown_seconds: float | None
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
# An event matching no rule keeps today's behaviour: warning, every transport, the global throttle.
|
|
302
|
+
_DEFAULT_DECISION = _RuleDecision(
|
|
303
|
+
severity=AlertSeverity.WARNING.value, transports=None, cooldown_seconds=None
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
class AlertRuleSet:
|
|
308
|
+
"""Evaluate operator alert rules (ADR 0014) against an event, returning the **first** matching
|
|
309
|
+
rule's :class:`_RuleDecision` (severity / transports / cooldown) or the default. Pure and
|
|
310
|
+
synchronous, so the notifier consults it inline on the worker and it is unit-testable without the
|
|
311
|
+
async sink."""
|
|
312
|
+
|
|
313
|
+
def __init__(self, rules: Sequence[AlertRule]) -> None:
|
|
314
|
+
self._rules = tuple(rules)
|
|
315
|
+
|
|
316
|
+
def decide(self, event: Mapping[str, Any]) -> _RuleDecision:
|
|
317
|
+
for rule in self._rules:
|
|
318
|
+
if self._matches(rule, event):
|
|
319
|
+
transports = None if rule.transports is None else tuple(rule.transports)
|
|
320
|
+
return _RuleDecision(rule.severity.value, transports, rule.cooldown_seconds)
|
|
321
|
+
return _DEFAULT_DECISION
|
|
322
|
+
|
|
323
|
+
@staticmethod
|
|
324
|
+
def _matches(rule: AlertRule, event: Mapping[str, Any]) -> bool:
|
|
325
|
+
etype = str(event.get("type", ""))
|
|
326
|
+
if rule.event_type != "any" and rule.event_type != etype:
|
|
327
|
+
return False
|
|
328
|
+
# Case-sensitive, OS-independent glob (connection names are case-sensitive).
|
|
329
|
+
if not fnmatch.fnmatchcase(str(event.get("connection", "")), rule.connection):
|
|
330
|
+
return False
|
|
331
|
+
# Depth/age thresholds apply only to queue_buildup — a rule that sets one never matches another
|
|
332
|
+
# event type (you can't be "over depth" on a stopped connection).
|
|
333
|
+
if rule.min_depth is not None:
|
|
334
|
+
if etype != "queue_buildup" or int(event.get("depth", 0)) < rule.min_depth:
|
|
335
|
+
return False
|
|
336
|
+
if rule.min_oldest_seconds is not None:
|
|
337
|
+
if etype != "queue_buildup" or (
|
|
338
|
+
float(event.get("oldest_age_seconds", 0.0)) < rule.min_oldest_seconds
|
|
339
|
+
):
|
|
340
|
+
return False
|
|
341
|
+
return True
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
class NotifierAlertSink(_BackgroundDispatcher[dict[str, Any]]):
|
|
345
|
+
""":class:`AlertSink` that fans each event out to one or more :class:`AlertTransport` on a
|
|
346
|
+
background task, with a per-(event, connection) re-alert throttle. An optional :class:`AlertRuleSet`
|
|
347
|
+
(ADR 0014) refines each event's severity, which transports fire, the cooldown, and suppression;
|
|
348
|
+
with no rules the behaviour is byte-identical (every event → every transport, global throttle).
|
|
349
|
+
|
|
350
|
+
The emit methods are synchronous and return immediately (enqueue only). Call :meth:`start` once a
|
|
351
|
+
loop is running (the ASGI lifespan does this) and :meth:`aclose` on shutdown."""
|
|
352
|
+
|
|
353
|
+
def __init__(
|
|
354
|
+
self,
|
|
355
|
+
transports: list[AlertTransport],
|
|
356
|
+
*,
|
|
357
|
+
realert_seconds: float = 300.0,
|
|
358
|
+
rules: Sequence[AlertRule] | None = None,
|
|
359
|
+
) -> None:
|
|
360
|
+
super().__init__()
|
|
361
|
+
self._transports = transports
|
|
362
|
+
self._realert_seconds = realert_seconds
|
|
363
|
+
self._rules = AlertRuleSet(rules or [])
|
|
364
|
+
self._last_sent: dict[str, float] = {}
|
|
365
|
+
|
|
366
|
+
# --- AlertSink emit methods (sync, non-blocking) -------------------------
|
|
367
|
+
|
|
368
|
+
def connection_stopped(self, name: str, *, detail: str) -> None:
|
|
369
|
+
self._emit({"type": "connection_stopped", "connection": name, "detail": detail})
|
|
370
|
+
|
|
371
|
+
def queue_buildup(self, name: str, *, depth: int, oldest_age_seconds: float) -> None:
|
|
372
|
+
self._emit(
|
|
373
|
+
{
|
|
374
|
+
"type": "queue_buildup",
|
|
375
|
+
"connection": name,
|
|
376
|
+
"depth": depth,
|
|
377
|
+
"oldest_age_seconds": round(oldest_age_seconds, 1),
|
|
378
|
+
}
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
def storage_threshold(self, path: str, *, size_bytes: int, limit_bytes: int) -> None:
|
|
382
|
+
# The DB path stands in for "connection" so the realert throttle + subject keying work
|
|
383
|
+
# uniformly; the event carries no message content (no PHI), only sizes.
|
|
384
|
+
self._emit(
|
|
385
|
+
{
|
|
386
|
+
"type": "storage_threshold",
|
|
387
|
+
"connection": path,
|
|
388
|
+
"size_bytes": size_bytes,
|
|
389
|
+
"limit_bytes": limit_bytes,
|
|
390
|
+
}
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
def cert_expiry(self, name: str, *, path: str, not_after: str, days_remaining: int) -> None:
|
|
394
|
+
# The cert label stands in for "connection" so the realert throttle keys per cert; the payload
|
|
395
|
+
# carries the PEM path + expiry only (no key material, no message content — no PHI).
|
|
396
|
+
self._emit(
|
|
397
|
+
{
|
|
398
|
+
"type": "cert_expiry",
|
|
399
|
+
"connection": name,
|
|
400
|
+
"path": path,
|
|
401
|
+
"not_after": not_after,
|
|
402
|
+
"days_remaining": days_remaining,
|
|
403
|
+
}
|
|
404
|
+
)
|
|
405
|
+
|
|
406
|
+
def _emit(self, event: dict[str, Any]) -> None:
|
|
407
|
+
decision = self._rules.decide(event)
|
|
408
|
+
if decision.transports is not None and not decision.transports:
|
|
409
|
+
return # a rule suppressed this event (transports = [])
|
|
410
|
+
cooldown = (
|
|
411
|
+
decision.cooldown_seconds
|
|
412
|
+
if decision.cooldown_seconds is not None
|
|
413
|
+
else self._realert_seconds
|
|
414
|
+
)
|
|
415
|
+
key = f"{event['type']}:{event['connection']}"
|
|
416
|
+
now = time.monotonic()
|
|
417
|
+
last = self._last_sent.get(key)
|
|
418
|
+
if last is not None and now - last < cooldown:
|
|
419
|
+
return # throttled — same event/connection notified too recently
|
|
420
|
+
self._last_sent[key] = now
|
|
421
|
+
event["ts"] = time.time()
|
|
422
|
+
event["severity"] = (
|
|
423
|
+
decision.severity
|
|
424
|
+
) # carried in the payload (webhook JSON / email subject)
|
|
425
|
+
if decision.transports is not None:
|
|
426
|
+
event["_transports"] = list(decision.transports) # internal routing; popped before send
|
|
427
|
+
self._enqueue(event, dropped=f"{event['type']} for {event['connection']!r}")
|
|
428
|
+
|
|
429
|
+
async def _handle(self, event: dict[str, Any]) -> None:
|
|
430
|
+
targets = event.pop("_transports", None) # a rule's transport subset (None = all)
|
|
431
|
+
for transport in self._transports:
|
|
432
|
+
if targets is not None and transport.name not in targets:
|
|
433
|
+
continue # this transport isn't in the matching rule's routing
|
|
434
|
+
try:
|
|
435
|
+
await transport.send(event)
|
|
436
|
+
except Exception:
|
|
437
|
+
# Best-effort: a failing transport must not drop the event for the others, nor
|
|
438
|
+
# ever propagate into the engine. Detail (not PHI) is in the event itself.
|
|
439
|
+
log.warning(
|
|
440
|
+
"alert transport %s failed for %s/%r",
|
|
441
|
+
transport.name,
|
|
442
|
+
event.get("type"),
|
|
443
|
+
event.get("connection"),
|
|
444
|
+
exc_info=True,
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def notifier_from_settings(alerts: AlertsSettings) -> NotifierAlertSink | None:
|
|
449
|
+
"""Build a :class:`NotifierAlertSink` from ``[alerts]`` settings, or ``None`` when no transport is
|
|
450
|
+
configured (the caller then leaves the engine on its default logging sink)."""
|
|
451
|
+
transports: list[AlertTransport] = []
|
|
452
|
+
if alerts.webhook_url:
|
|
453
|
+
transports.append(
|
|
454
|
+
WebhookTransport(
|
|
455
|
+
alerts.webhook_url,
|
|
456
|
+
timeout=alerts.webhook_timeout,
|
|
457
|
+
allowed_hosts=tuple(alerts.webhook_allowed_hosts),
|
|
458
|
+
)
|
|
459
|
+
)
|
|
460
|
+
if alerts.email_smtp_host and alerts.email_from and alerts.email_to:
|
|
461
|
+
transports.append(
|
|
462
|
+
EmailTransport(
|
|
463
|
+
host=alerts.email_smtp_host,
|
|
464
|
+
port=alerts.email_smtp_port,
|
|
465
|
+
sender=alerts.email_from,
|
|
466
|
+
recipients=list(alerts.email_to),
|
|
467
|
+
use_tls=alerts.email_use_tls,
|
|
468
|
+
username=alerts.email_username,
|
|
469
|
+
password=alerts.email_password,
|
|
470
|
+
timeout=alerts.email_timeout,
|
|
471
|
+
allowed_hosts=tuple(alerts.smtp_allowed_hosts),
|
|
472
|
+
)
|
|
473
|
+
)
|
|
474
|
+
if not transports:
|
|
475
|
+
return None
|
|
476
|
+
# Fail loud at config time if a rule routes to a transport that isn't configured (a typo or a
|
|
477
|
+
# missing webhook_url/email block) — caught at startup/reload, not silently swallowed at send.
|
|
478
|
+
configured = {t.name for t in transports}
|
|
479
|
+
for i, rule in enumerate(alerts.rules):
|
|
480
|
+
unknown = [t for t in (rule.transports or []) if t not in configured]
|
|
481
|
+
if unknown:
|
|
482
|
+
raise ValueError(
|
|
483
|
+
f"[alerts].rules[{i}] routes to unconfigured transport(s) {unknown}; "
|
|
484
|
+
f"configured: {sorted(configured)}"
|
|
485
|
+
)
|
|
486
|
+
return NotifierAlertSink(transports, realert_seconds=alerts.realert_seconds, rules=alerts.rules)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 MessageFoundry Organization and contributors
|
|
3
|
+
"""Operational alert emit-points for the delivery pipeline.
|
|
4
|
+
|
|
5
|
+
The conservative ordering defaults (FIFO head-of-line blocking, retry-forever, stop-connection on
|
|
6
|
+
internal error) are only *safe* if an operator is told when a lane stalls — a stopped connection or a
|
|
7
|
+
building backlog needs a human. A full alerting/notification framework is future work
|
|
8
|
+
(``docs/BACKLOG.md`` item 5); until it lands, the delivery worker emits these events to an
|
|
9
|
+
:class:`AlertSink` whose default implementation simply **logs** them at ``WARNING``. Wiring a real
|
|
10
|
+
notifier later is then a matter of passing a different sink to the
|
|
11
|
+
:class:`~messagefoundry.pipeline.wiring_runner.RegistryRunner` — the emit-points don't change.
|
|
12
|
+
|
|
13
|
+
This module is engine-side and dependency-light (stdlib logging only), so it never pulls the API or
|
|
14
|
+
console into the engine.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import logging
|
|
20
|
+
from typing import Protocol
|
|
21
|
+
|
|
22
|
+
__all__ = ["AlertSink", "LoggingAlertSink"]
|
|
23
|
+
|
|
24
|
+
log = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class AlertSink(Protocol):
|
|
28
|
+
"""Where the delivery pipeline reports operational stalls. A real notifier (email/PagerDuty/…)
|
|
29
|
+
implements this later; today the default :class:`LoggingAlertSink` just logs.
|
|
30
|
+
|
|
31
|
+
Implementations must be cheap and non-blocking — they run inline on a delivery worker, so a slow
|
|
32
|
+
sink would stall the lane it's reporting on. Never raise: an alert failure must not break delivery.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def connection_stopped(self, name: str, *, detail: str) -> None:
|
|
36
|
+
"""An outbound connection's delivery worker halted (``InternalErrorPolicy.STOP`` fired on an
|
|
37
|
+
internal/code error). The lane is frozen until an operator intervenes (fix + reload/restart)."""
|
|
38
|
+
...
|
|
39
|
+
|
|
40
|
+
def queue_buildup(self, name: str, *, depth: int, oldest_age_seconds: float) -> None:
|
|
41
|
+
"""An outbound connection's backlog crossed a depth / oldest-in-lane-age threshold — e.g. a
|
|
42
|
+
retry-forever head is blocking the lane. (Emitted by the buildup detector — ordering Layer 4b.)"""
|
|
43
|
+
...
|
|
44
|
+
|
|
45
|
+
def storage_threshold(self, path: str, *, size_bytes: int, limit_bytes: int) -> None:
|
|
46
|
+
"""The message store grew past the configured ``[retention] max_db_mb`` advisory threshold.
|
|
47
|
+
Emitted by the :class:`~messagefoundry.pipeline.retention.RetentionRunner` once per pass while
|
|
48
|
+
over the limit; ``path`` identifies the DB, never any message content (no PHI)."""
|
|
49
|
+
...
|
|
50
|
+
|
|
51
|
+
def cert_expiry(self, name: str, *, path: str, not_after: str, days_remaining: int) -> None:
|
|
52
|
+
"""A served TLS certificate is expired or within the configured warn window. ``name`` labels
|
|
53
|
+
which cert (``"api"`` or the connection name); ``path`` is the PEM file; ``not_after`` is the
|
|
54
|
+
ISO expiry; ``days_remaining`` is negative once expired. No key material is read or logged.
|
|
55
|
+
Emitted by the :class:`~messagefoundry.pipeline.cert_expiry.CertExpiryRunner`."""
|
|
56
|
+
...
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class LoggingAlertSink:
|
|
60
|
+
"""Default :class:`AlertSink`: log each event at ``WARNING``. No PHI — only the connection name
|
|
61
|
+
and queue shape are recorded, never a message body."""
|
|
62
|
+
|
|
63
|
+
def connection_stopped(self, name: str, *, detail: str) -> None:
|
|
64
|
+
log.warning(
|
|
65
|
+
"ALERT connection_stopped: outbound %r halted on internal error: %s", name, detail
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
def queue_buildup(self, name: str, *, depth: int, oldest_age_seconds: float) -> None:
|
|
69
|
+
log.warning(
|
|
70
|
+
"ALERT queue_buildup: outbound %r backlog depth=%d oldest=%.0fs",
|
|
71
|
+
name,
|
|
72
|
+
depth,
|
|
73
|
+
oldest_age_seconds,
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def storage_threshold(self, path: str, *, size_bytes: int, limit_bytes: int) -> None:
|
|
77
|
+
log.warning(
|
|
78
|
+
"ALERT storage_threshold: store %r is %.1f MB, over the %.1f MB retention limit",
|
|
79
|
+
path,
|
|
80
|
+
size_bytes / 1_000_000,
|
|
81
|
+
limit_bytes / 1_000_000,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
def cert_expiry(self, name: str, *, path: str, not_after: str, days_remaining: int) -> None:
|
|
85
|
+
if days_remaining < 0:
|
|
86
|
+
log.warning(
|
|
87
|
+
"ALERT cert_expiry: %r certificate (%s) EXPIRED %d day(s) ago (not_after=%s)",
|
|
88
|
+
name,
|
|
89
|
+
path,
|
|
90
|
+
-days_remaining,
|
|
91
|
+
not_after,
|
|
92
|
+
)
|
|
93
|
+
else:
|
|
94
|
+
log.warning(
|
|
95
|
+
"ALERT cert_expiry: %r certificate (%s) expires in %d day(s) (not_after=%s)",
|
|
96
|
+
name,
|
|
97
|
+
path,
|
|
98
|
+
days_remaining,
|
|
99
|
+
not_after,
|
|
100
|
+
)
|