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,307 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 MessageFoundry Organization and contributors
|
|
3
|
+
"""Raw-TCP transport with **configurable delimiter framing** — source + destination.
|
|
4
|
+
|
|
5
|
+
Built to relay **X12 (and other non-HL7) feeds over custom-framed TCP** opaquely: the payload
|
|
6
|
+
is carried as bytes and never parsed (no ISA/GS/ST inspection, no 997/TA1 acks — those are a
|
|
7
|
+
documented follow-up). Whether a received body is routed as a structured HL7
|
|
8
|
+
:class:`~messagefoundry.parsing.message.Message` or a
|
|
9
|
+
:class:`~messagefoundry.parsing.message.RawMessage` is decided by the **inbound's
|
|
10
|
+
``content_type``** (set ``x12`` for these feeds), not by this connector (ADR 0004).
|
|
11
|
+
|
|
12
|
+
Framing is the shared :mod:`messagefoundry.transports.framing` codec, configured per connection
|
|
13
|
+
by a preset name (``stx_etx`` / ``vt_fs`` / ``mllp``) or explicit ``start``/``end``/``trailer``
|
|
14
|
+
delimiter byte ints. The 8 X12-over-TCP feeds we target split STX/ETX (``0x02``/``0x03``) and
|
|
15
|
+
VT/FS (the same bytes MLLP uses). The listener mirrors :class:`~messagefoundry.transports.mllp.MLLPSource`'s
|
|
16
|
+
DoS guards (``max_connections`` / ``receive_timeout`` / ``max_frame_bytes``) and cooperative stop.
|
|
17
|
+
|
|
18
|
+
**No HL7 ACK.** The source hands each deframed payload to the pipeline handler; if the handler
|
|
19
|
+
returns a non-``None`` reply it is framed and sent back on the same connection (so a Handler
|
|
20
|
+
*could* emit a framed reply), otherwise nothing is sent (fire-and-forget). The destination frames
|
|
21
|
+
and sends; with ``expect_reply`` it reads one framed reply (bounded by timeout + max-frame) and
|
|
22
|
+
treats any received frame as confirmation — it does **not** parse or validate the reply.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import asyncio
|
|
28
|
+
import logging
|
|
29
|
+
from collections.abc import Callable
|
|
30
|
+
|
|
31
|
+
from messagefoundry.config.models import ConnectorType, Destination, Source
|
|
32
|
+
from messagefoundry.redaction import safe_exc
|
|
33
|
+
from messagefoundry.transports.base import (
|
|
34
|
+
DeliveryError,
|
|
35
|
+
DeliveryResponse,
|
|
36
|
+
DestinationConnector,
|
|
37
|
+
InboundHandler,
|
|
38
|
+
SourceConnector,
|
|
39
|
+
peer_ip_allowed,
|
|
40
|
+
probe_tcp_reachable,
|
|
41
|
+
register_destination,
|
|
42
|
+
register_source,
|
|
43
|
+
)
|
|
44
|
+
from messagefoundry.transports.framing import FrameCodec, FrameError, codec_for
|
|
45
|
+
from messagefoundry.transports.mllp import (
|
|
46
|
+
DEFAULT_MAX_CONNECTIONS,
|
|
47
|
+
DEFAULT_MAX_FRAME_BYTES,
|
|
48
|
+
DEFAULT_RECEIVE_TIMEOUT,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
__all__ = ["TcpSource", "TcpDestination"]
|
|
52
|
+
|
|
53
|
+
logger = logging.getLogger(__name__)
|
|
54
|
+
|
|
55
|
+
# Established clients are closed and their handlers given this long to finish an in-flight commit on
|
|
56
|
+
# stop()/reload before the connection tasks are cancelled (mirrors MLLPSource; bounds shutdown).
|
|
57
|
+
_CLIENT_SHUTDOWN_GRACE = 5.0
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _codec_from_settings(settings: dict[str, object]) -> FrameCodec:
|
|
61
|
+
"""Resolve the connection's :class:`FrameCodec` from its settings (preset OR explicit bytes).
|
|
62
|
+
|
|
63
|
+
Validates at construction so a bad preset/byte fails when the connector is built (caught in
|
|
64
|
+
dry-run / ``messagefoundry check``), not deep in a read loop."""
|
|
65
|
+
framing = settings.get("framing")
|
|
66
|
+
try:
|
|
67
|
+
return codec_for(
|
|
68
|
+
None if framing is None else str(framing),
|
|
69
|
+
start=_opt_byte(settings.get("start")),
|
|
70
|
+
end=_opt_byte(settings.get("end")),
|
|
71
|
+
trailer=_opt_byte(settings.get("trailer")),
|
|
72
|
+
)
|
|
73
|
+
except ValueError as exc:
|
|
74
|
+
raise ValueError(f"TCP framing misconfigured: {exc}") from exc
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _opt_byte(value: object) -> int | None:
|
|
78
|
+
"""Coerce an optional delimiter setting to an int (``None`` passes through). A non-int value
|
|
79
|
+
raises ``ValueError`` so a mistyped delimiter fails loud at construction."""
|
|
80
|
+
if value is None:
|
|
81
|
+
return None
|
|
82
|
+
if isinstance(value, bool) or not isinstance(value, (int, str)):
|
|
83
|
+
raise ValueError(f"delimiter byte must be an int in 0..255, got {value!r}")
|
|
84
|
+
return int(value)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# --- destination -------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class TcpDestination(DestinationConnector):
|
|
91
|
+
"""Send a payload to a raw-TCP receiver with the configured framing, relayed opaquely.
|
|
92
|
+
|
|
93
|
+
Opens a fresh connection per delivery (simple, robust to flaky peers; pooling can come later).
|
|
94
|
+
Any connect/IO/timeout raises :class:`DeliveryError`, so the pipeline retries. With
|
|
95
|
+
``expect_reply`` it waits for one framed reply and treats receiving any frame as confirmation —
|
|
96
|
+
it does **not** parse the reply (no ACK/NAK semantics; X12 acks are deferred).
|
|
97
|
+
|
|
98
|
+
Note (at-least-once): a payload sent whose reply (when expected) is lost is re-delivered on
|
|
99
|
+
retry — the receiver may see a **duplicate**, so the outbound receiver must be idempotent.
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
def __init__(self, config: Destination) -> None:
|
|
103
|
+
s = config.settings
|
|
104
|
+
self.host: str = s.get("host", "127.0.0.1")
|
|
105
|
+
self.port: int = int(s["port"])
|
|
106
|
+
self.codec = _codec_from_settings(s)
|
|
107
|
+
self.timeout: float = float(s.get("timeout_seconds", 30.0))
|
|
108
|
+
self.connect_timeout: float = float(s.get("connect_timeout", 10.0))
|
|
109
|
+
self.encoding: str = s.get("encoding", "utf-8")
|
|
110
|
+
self.expect_reply: bool = bool(s.get("expect_reply", False))
|
|
111
|
+
mf = s.get("max_frame_bytes", DEFAULT_MAX_FRAME_BYTES)
|
|
112
|
+
self.max_frame_bytes: int | None = int(mf) if mf else None
|
|
113
|
+
# ADR 0013: capture the framed reply. Requires expect_reply=True (enforced at wiring). A missing
|
|
114
|
+
# reply is already a retryable DeliveryError (peer-close in _read_reply) and stays one — enabling
|
|
115
|
+
# capture does NOT change delivery semantics, it only returns the frame that was already read.
|
|
116
|
+
self.capture_response: bool = bool(s.get("capture_response", False))
|
|
117
|
+
|
|
118
|
+
async def test_connection(self) -> None:
|
|
119
|
+
# Reachability only: open + close a connection (no frame sent) so a test never delivers.
|
|
120
|
+
await probe_tcp_reachable(self.host, self.port, self.connect_timeout, "TCP")
|
|
121
|
+
|
|
122
|
+
async def send(self, payload: str) -> DeliveryResponse | None:
|
|
123
|
+
try:
|
|
124
|
+
reader, writer = await asyncio.wait_for(
|
|
125
|
+
asyncio.open_connection(self.host, self.port), self.connect_timeout
|
|
126
|
+
)
|
|
127
|
+
except (OSError, asyncio.TimeoutError) as exc:
|
|
128
|
+
raise DeliveryError(f"TCP connect to {self.host}:{self.port} failed: {exc}") from exc
|
|
129
|
+
reply: bytes | None = None
|
|
130
|
+
try:
|
|
131
|
+
writer.write(self.codec.frame(payload, self.encoding))
|
|
132
|
+
await asyncio.wait_for(writer.drain(), self.timeout)
|
|
133
|
+
if self.expect_reply:
|
|
134
|
+
reply = await asyncio.wait_for(self._read_reply(reader), self.timeout)
|
|
135
|
+
except asyncio.TimeoutError as exc:
|
|
136
|
+
raise DeliveryError("TCP timed out") from exc
|
|
137
|
+
except OSError as exc:
|
|
138
|
+
raise DeliveryError(f"TCP I/O error: {exc}") from exc
|
|
139
|
+
finally:
|
|
140
|
+
writer.close()
|
|
141
|
+
try:
|
|
142
|
+
await writer.wait_closed()
|
|
143
|
+
except OSError:
|
|
144
|
+
pass
|
|
145
|
+
if self.capture_response and reply is not None:
|
|
146
|
+
return DeliveryResponse(
|
|
147
|
+
body=reply.decode(self.encoding, errors="replace"), outcome="accepted"
|
|
148
|
+
)
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
async def _read_reply(self, reader: asyncio.StreamReader) -> bytes:
|
|
152
|
+
"""Read one framed reply; any frame counts as confirmation (the bytes are not inspected)."""
|
|
153
|
+
decoder = self.codec.decoder(max_frame_bytes=self.max_frame_bytes)
|
|
154
|
+
while True:
|
|
155
|
+
chunk = await reader.read(4096)
|
|
156
|
+
if not chunk:
|
|
157
|
+
raise DeliveryError("TCP peer closed before sending a reply")
|
|
158
|
+
try:
|
|
159
|
+
for message in decoder.feed(chunk):
|
|
160
|
+
return message
|
|
161
|
+
except FrameError as exc:
|
|
162
|
+
raise DeliveryError(f"reply exceeded max frame size: {exc}") from exc
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# --- source ------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
class TcpSource(SourceConnector):
|
|
169
|
+
"""Listen for inbound raw-TCP connections, deframe each message with the configured codec, and
|
|
170
|
+
hand its **raw bytes** to the pipeline handler. No HL7 ACK: if the handler returns a non-``None``
|
|
171
|
+
reply, frame and send it on the same connection; otherwise send nothing (fire-and-forget)."""
|
|
172
|
+
|
|
173
|
+
def __init__(self, config: Source) -> None:
|
|
174
|
+
s = config.settings
|
|
175
|
+
# The bind interface is injected from the service's [inbound].bind_host (authors never set a
|
|
176
|
+
# host on an inbound). Fall back to loopback for a missing/None value — never bind all
|
|
177
|
+
# interfaces (0.0.0.0) by accident, since raw TCP has no transport auth. See docs/CONNECTIONS.md.
|
|
178
|
+
self.host: str = s.get("host") or "127.0.0.1"
|
|
179
|
+
self.port: int = int(s["port"])
|
|
180
|
+
self.codec = _codec_from_settings(s)
|
|
181
|
+
self.encoding: str = s.get("encoding", "utf-8")
|
|
182
|
+
# Caps below: key absent → secure default; present-but-falsy (None/0) → disabled.
|
|
183
|
+
mc = s.get("max_connections", DEFAULT_MAX_CONNECTIONS)
|
|
184
|
+
self.max_connections: int | None = int(mc) if mc else None
|
|
185
|
+
rt = s.get("receive_timeout", DEFAULT_RECEIVE_TIMEOUT)
|
|
186
|
+
self.receive_timeout: float | None = float(rt) if rt else None
|
|
187
|
+
mf = s.get("max_frame_bytes", DEFAULT_MAX_FRAME_BYTES)
|
|
188
|
+
self.max_frame_bytes: int | None = int(mf) if mf else None
|
|
189
|
+
# Per-connection peer-IP allowlist (Tier 4 operability): refuse a non-listed peer at accept.
|
|
190
|
+
# Absent/empty = no restriction. Mirrors MLLPSource.
|
|
191
|
+
sa = s.get("source_ip_allowlist")
|
|
192
|
+
self.source_ip_allowlist: list[str] | None = [str(x) for x in sa] if sa else None
|
|
193
|
+
self._server: asyncio.Server | None = None
|
|
194
|
+
self._handler: InboundHandler | None = None
|
|
195
|
+
self._active = 0
|
|
196
|
+
# Live client writers + handler tasks so stop()/reload can actively close established
|
|
197
|
+
# connections and bound the wait (mirrors MLLPSource, review H-2).
|
|
198
|
+
self._clients: set[asyncio.StreamWriter] = set()
|
|
199
|
+
self._client_tasks: set[asyncio.Task[None]] = set()
|
|
200
|
+
|
|
201
|
+
async def start(
|
|
202
|
+
self, handler: InboundHandler, *, leader_gate: Callable[[], bool] | None = None
|
|
203
|
+
) -> None:
|
|
204
|
+
# leader_gate is ignored: a listen source runs on every node (each binds its own endpoint),
|
|
205
|
+
# so there is no shared-resource double-read to gate. Accepted only so the runner's call is
|
|
206
|
+
# uniform across all sources (mirrors MLLPSource).
|
|
207
|
+
self._handler = handler
|
|
208
|
+
self._server = await asyncio.start_server(self._on_client, self.host, self.port)
|
|
209
|
+
|
|
210
|
+
@property
|
|
211
|
+
def sockport(self) -> int:
|
|
212
|
+
"""The actual bound port (useful when configured with port 0 in tests)."""
|
|
213
|
+
assert self._server is not None
|
|
214
|
+
port: int = self._server.sockets[0].getsockname()[1]
|
|
215
|
+
return port
|
|
216
|
+
|
|
217
|
+
async def stop(self) -> None:
|
|
218
|
+
if self._server is not None:
|
|
219
|
+
self._server.close()
|
|
220
|
+
# Close established clients BEFORE awaiting the server (server.wait_closed() hangs on
|
|
221
|
+
# py3.12.1+ waiting for in-flight handlers of a peer holding its connection open). A message
|
|
222
|
+
# mid-handler still finishes its commit (the body is durably stored before any reply, so
|
|
223
|
+
# at-least-once holds). Then await the connection tasks with a bounded grace (review H-2).
|
|
224
|
+
for writer in list(self._clients):
|
|
225
|
+
writer.close()
|
|
226
|
+
pending = [task for task in self._client_tasks if not task.done()]
|
|
227
|
+
if pending:
|
|
228
|
+
_done, still_running = await asyncio.wait(pending, timeout=_CLIENT_SHUTDOWN_GRACE)
|
|
229
|
+
for task in still_running:
|
|
230
|
+
task.cancel()
|
|
231
|
+
if still_running:
|
|
232
|
+
await asyncio.gather(*still_running, return_exceptions=True)
|
|
233
|
+
self._clients.clear()
|
|
234
|
+
self._client_tasks.clear()
|
|
235
|
+
if self._server is not None:
|
|
236
|
+
await self._server.wait_closed()
|
|
237
|
+
self._server = None
|
|
238
|
+
|
|
239
|
+
async def _on_client(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
|
240
|
+
assert self._handler is not None
|
|
241
|
+
# Register before anything else so stop() can always find + close this connection (H-2).
|
|
242
|
+
task = asyncio.current_task()
|
|
243
|
+
self._clients.add(writer)
|
|
244
|
+
if task is not None:
|
|
245
|
+
self._client_tasks.add(task)
|
|
246
|
+
try:
|
|
247
|
+
if self.source_ip_allowlist is not None:
|
|
248
|
+
peer = writer.get_extra_info("peername")
|
|
249
|
+
if not peer_ip_allowed(peer, self.source_ip_allowlist):
|
|
250
|
+
logger.warning(
|
|
251
|
+
"TCP connection from %s refused: not in source_ip_allowlist", peer
|
|
252
|
+
)
|
|
253
|
+
return # not allowlisted — refuse (closed in the outer finally; _active untouched)
|
|
254
|
+
if self.max_connections is not None and self._active >= self.max_connections:
|
|
255
|
+
return # at capacity — refuse the new client (closed in the outer finally)
|
|
256
|
+
self._active += 1
|
|
257
|
+
try:
|
|
258
|
+
decoder = self.codec.decoder(max_frame_bytes=self.max_frame_bytes)
|
|
259
|
+
while True:
|
|
260
|
+
if self.receive_timeout:
|
|
261
|
+
try:
|
|
262
|
+
chunk = await asyncio.wait_for(reader.read(4096), self.receive_timeout)
|
|
263
|
+
except asyncio.TimeoutError:
|
|
264
|
+
break # idle past receive_timeout — close the connection
|
|
265
|
+
else:
|
|
266
|
+
chunk = await reader.read(4096)
|
|
267
|
+
if not chunk:
|
|
268
|
+
break
|
|
269
|
+
try:
|
|
270
|
+
for message in decoder.feed(chunk):
|
|
271
|
+
reply = await self._handler(message)
|
|
272
|
+
if reply is not None:
|
|
273
|
+
writer.write(self.codec.frame(reply, self.encoding))
|
|
274
|
+
await writer.drain()
|
|
275
|
+
except FrameError as exc:
|
|
276
|
+
peer = writer.get_extra_info("peername")
|
|
277
|
+
logger.warning(
|
|
278
|
+
"TCP frame from %s over cap; closing connection: %s", peer, exc
|
|
279
|
+
)
|
|
280
|
+
break # drop the connection rather than buffer without bound
|
|
281
|
+
except OSError:
|
|
282
|
+
raise # peer reset / write failure → handled by the outer OSError catch (quiet)
|
|
283
|
+
except Exception as exc:
|
|
284
|
+
# Last-resort (ASVS 16.5.4): an unexpected handler/codec error must not let the
|
|
285
|
+
# per-connection task die silently or leak detail. Log redacted; drop the conn.
|
|
286
|
+
peer = writer.get_extra_info("peername")
|
|
287
|
+
logger.error(
|
|
288
|
+
"TCP connection from %s failed unexpectedly: %s", peer, safe_exc(exc)
|
|
289
|
+
)
|
|
290
|
+
break
|
|
291
|
+
except OSError:
|
|
292
|
+
pass # peer reset; nothing to do but drop the connection
|
|
293
|
+
finally:
|
|
294
|
+
self._active -= 1
|
|
295
|
+
finally:
|
|
296
|
+
self._clients.discard(writer)
|
|
297
|
+
if task is not None:
|
|
298
|
+
self._client_tasks.discard(task)
|
|
299
|
+
writer.close()
|
|
300
|
+
try:
|
|
301
|
+
await writer.wait_closed()
|
|
302
|
+
except OSError:
|
|
303
|
+
pass
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
register_destination(ConnectorType.TCP, TcpDestination)
|
|
307
|
+
register_source(ConnectorType.TCP, TcpSource)
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 MessageFoundry Organization and contributors
|
|
3
|
+
"""Timer / scheduled source — emit a configured body on a schedule (ADR 0011).
|
|
4
|
+
|
|
5
|
+
Unlike every other source, a timer reads **no external resource**: it *fires* on a clock and hands
|
|
6
|
+
an operator-configured ``body`` to the pipeline handler. It follows the File/Database poll skeleton
|
|
7
|
+
(a cooperatively-cancellable :mod:`asyncio` loop that logs-and-continues on error, never crashing the
|
|
8
|
+
connection) but drops the resource scan, content validation, and mark/move — each tick just emits the
|
|
9
|
+
body.
|
|
10
|
+
|
|
11
|
+
A timer is **leader-gated** (:attr:`polls_shared_resource` ``True``): the schedule is a *shared
|
|
12
|
+
trigger*, so in a cluster only the leader fires it — otherwise every node would emit each message. On
|
|
13
|
+
a single node ``NullCoordinator.is_leader()`` is always ``True``, so behaviour is byte-identical to an
|
|
14
|
+
ungated loop (the runner already passes ``leader_gate=is_leader`` to every source). See ADR 0011 for
|
|
15
|
+
the at-least-once / re-run contract: the body is committed to the ingress stage and frozen there, so
|
|
16
|
+
downstream re-runs stay pure; only the *timing* boundary is at-least-once (a clock, not a queue).
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import asyncio
|
|
22
|
+
import logging
|
|
23
|
+
from typing import Callable
|
|
24
|
+
|
|
25
|
+
from messagefoundry.config.models import ConnectorType, Source
|
|
26
|
+
from messagefoundry.transports.base import InboundHandler, SourceConnector, register_source
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger(__name__)
|
|
29
|
+
|
|
30
|
+
# How often a not-yet-fired ``run_once`` timer re-checks leadership when no interval is configured
|
|
31
|
+
# (so a follower that wins leadership fires reasonably promptly). On a single node it fires on the
|
|
32
|
+
# first tick regardless, so this only matters in a cluster.
|
|
33
|
+
DEFAULT_RUN_ONCE_POLL_SECONDS = 1.0
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class TimerSource(SourceConnector):
|
|
37
|
+
"""Emit a configured ``body`` on an interval (or exactly once), feeding it to the pipeline handler."""
|
|
38
|
+
|
|
39
|
+
# A schedule is a shared trigger — leader-gate it so exactly one node fires (see module docstring).
|
|
40
|
+
polls_shared_resource = True
|
|
41
|
+
|
|
42
|
+
def __init__(self, config: Source) -> None:
|
|
43
|
+
s = config.settings
|
|
44
|
+
if "body" not in s:
|
|
45
|
+
raise ValueError("timer source requires a 'body' setting")
|
|
46
|
+
self.body = str(s["body"])
|
|
47
|
+
self.encoding = str(s.get("encoding", "utf-8"))
|
|
48
|
+
# Pre-encode once: the emitted bytes are deterministic across fires (re-run-stable, ADR 0011 §5).
|
|
49
|
+
self._body_bytes = self.body.encode(self.encoding)
|
|
50
|
+
self.run_once = bool(s.get("run_once", False))
|
|
51
|
+
iv = s.get("interval_seconds")
|
|
52
|
+
self.interval_seconds: float | None = float(iv) if iv is not None else None
|
|
53
|
+
if self.interval_seconds is not None and self.interval_seconds <= 0:
|
|
54
|
+
raise ValueError("timer source 'interval_seconds' must be > 0")
|
|
55
|
+
if s.get("cron_expression"):
|
|
56
|
+
# The loop is cron-shaped, but the schedule calculation is a deliberate follow-up — the MVP
|
|
57
|
+
# is interval + run_once with no scheduling dependency (ADR 0011 §2). Reserve the setting
|
|
58
|
+
# name and fail loud so a config asking for cron is caught at wiring / `messagefoundry check`.
|
|
59
|
+
raise ValueError(
|
|
60
|
+
"timer source 'cron_expression' is not yet implemented "
|
|
61
|
+
"(use 'interval_seconds' or 'run_once')"
|
|
62
|
+
)
|
|
63
|
+
if self.interval_seconds is None and not self.run_once:
|
|
64
|
+
raise ValueError("timer source requires 'interval_seconds' or 'run_once'")
|
|
65
|
+
# The loop's sleep cadence: the interval when set, else a short re-check for a run_once follower.
|
|
66
|
+
self._tick_seconds = (
|
|
67
|
+
self.interval_seconds
|
|
68
|
+
if self.interval_seconds is not None
|
|
69
|
+
else DEFAULT_RUN_ONCE_POLL_SECONDS
|
|
70
|
+
)
|
|
71
|
+
self._handler: InboundHandler | None = None
|
|
72
|
+
# Leader-gate (Track B Step 4b): when set, only fire while it returns True so exactly one node
|
|
73
|
+
# emits. None = always fire (single-node / direct callers / tests) — byte-identical.
|
|
74
|
+
self._leader_gate: Callable[[], bool] | None = None
|
|
75
|
+
self._skipping = False # whether the last tick was gated out (for a single transition log)
|
|
76
|
+
self._fired = False # has run_once already fired? (so it fires once, then idles until stop)
|
|
77
|
+
self._stop = asyncio.Event()
|
|
78
|
+
self._task: asyncio.Task[None] | None = None
|
|
79
|
+
|
|
80
|
+
async def start(
|
|
81
|
+
self, handler: InboundHandler, *, leader_gate: Callable[[], bool] | None = None
|
|
82
|
+
) -> None:
|
|
83
|
+
"""Begin firing in the background. Returns once the source is set up so the caller can rely
|
|
84
|
+
on it being live (consistent with the other sources)."""
|
|
85
|
+
self._handler = handler
|
|
86
|
+
self._leader_gate = leader_gate
|
|
87
|
+
self._stop.clear()
|
|
88
|
+
self._fired = False
|
|
89
|
+
self._task = asyncio.create_task(self._run())
|
|
90
|
+
|
|
91
|
+
async def stop(self) -> None:
|
|
92
|
+
self._stop.set()
|
|
93
|
+
if self._task is not None:
|
|
94
|
+
# return_exceptions: a faulted timer task must not re-raise here — stop() runs during reload
|
|
95
|
+
# quiesce, outside its rollback. _run already guards each fire; this is belt-and-suspenders.
|
|
96
|
+
await asyncio.gather(self._task, return_exceptions=True)
|
|
97
|
+
self._task = None
|
|
98
|
+
|
|
99
|
+
async def _run(self) -> None:
|
|
100
|
+
# Fire immediately on start (a heartbeat starts at t=0), then every interval. The first tick of
|
|
101
|
+
# each pass checks the leader gate; a follower skips and re-checks next tick (reactive-by-polling).
|
|
102
|
+
while not self._stop.is_set():
|
|
103
|
+
try:
|
|
104
|
+
if self._may_fire():
|
|
105
|
+
await self._fire()
|
|
106
|
+
self._fired = True
|
|
107
|
+
except asyncio.CancelledError:
|
|
108
|
+
raise
|
|
109
|
+
except Exception:
|
|
110
|
+
# A fire failure is an infrastructure error (the durable ingress write failed — DB
|
|
111
|
+
# locked, disk full): the handler records every message-level outcome itself. It must
|
|
112
|
+
# NOT kill the source (that would silently stop intake while still reporting running).
|
|
113
|
+
# Log and retry on the next tick (at-least-once). _fired stays False, so a run_once timer
|
|
114
|
+
# retries until it lands.
|
|
115
|
+
logger.exception("timer source fire failed; retrying next tick")
|
|
116
|
+
if self.run_once and self._fired:
|
|
117
|
+
# Single-shot done: idle until stop so the task joins cleanly rather than busy-ticking.
|
|
118
|
+
await self._stop.wait()
|
|
119
|
+
return
|
|
120
|
+
try:
|
|
121
|
+
await asyncio.wait_for(self._stop.wait(), self._tick_seconds)
|
|
122
|
+
except asyncio.TimeoutError:
|
|
123
|
+
pass # tick elapsed; fire / re-check again
|
|
124
|
+
|
|
125
|
+
def _may_fire(self) -> bool:
|
|
126
|
+
"""Whether this tick may fire. False on a follower (leader-gated, Step 4b): a non-leader must
|
|
127
|
+
not emit, since the schedule is shared and two nodes firing would duplicate the message. The
|
|
128
|
+
loop still ticks, so a node that becomes leader fires on its next tick (no restart). When the
|
|
129
|
+
gate is None or True, behaves exactly as an ungated loop. Logged once per transition (never per
|
|
130
|
+
skipped tick — that would spam a follower's log every tick)."""
|
|
131
|
+
if self._leader_gate is None or self._leader_gate():
|
|
132
|
+
if self._skipping:
|
|
133
|
+
self._skipping = False
|
|
134
|
+
logger.debug("timer source resuming (now leader)")
|
|
135
|
+
return True
|
|
136
|
+
if not self._skipping:
|
|
137
|
+
self._skipping = True
|
|
138
|
+
logger.debug("timer source skipping fire (not leader; another node emits it)")
|
|
139
|
+
return False
|
|
140
|
+
|
|
141
|
+
async def _fire(self) -> None:
|
|
142
|
+
assert self._handler is not None
|
|
143
|
+
await self._handler(self._body_bytes)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
register_source(ConnectorType.TIMER, TimerSource)
|