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,664 @@
|
|
|
1
|
+
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
# Copyright (C) 2026 MessageFoundry Organization and contributors
|
|
3
|
+
"""Remote-file transport: SFTP / FTP / FTPS — directory destination + directory-polling source.
|
|
4
|
+
|
|
5
|
+
A single connector type (``REMOTEFILE``) with a ``protocol`` setting selecting the wire protocol:
|
|
6
|
+
|
|
7
|
+
- ``sftp`` — SSH file transfer (paramiko, the ``[sftp]`` extra — lazily imported, so installs that
|
|
8
|
+
never use SFTP skip it). **Host-key verification is ON by default**; an unknown key is refused
|
|
9
|
+
unless the explicit dev escape ``MEFOR_ALLOW_INSECURE_TLS`` is set (and logged loudly when it is),
|
|
10
|
+
mirroring the SQL Server backend's weakened-TLS posture.
|
|
11
|
+
- ``ftp`` — plain FTP (stdlib ``ftplib``). Cleartext: credentials over plain ``ftp`` are **refused**
|
|
12
|
+
unless the escape is set (use ``ftps``/``sftp``), mirroring :func:`refuse_cleartext_credentials`.
|
|
13
|
+
- ``ftps`` — FTP over explicit TLS (``ftplib.FTP_TLS`` + ``PROT P``), credentials encrypted.
|
|
14
|
+
|
|
15
|
+
**Destination** uploads each payload to ``remote_dir``/``filename`` (``{HL7-path}`` placeholders
|
|
16
|
+
resolved via :func:`render_filename`). The write goes to a temp name then a **rename** to the final
|
|
17
|
+
name, so a poller on the far side never sees a partial file. A name collision is uniquified (never a
|
|
18
|
+
silent clobber). A transient failure (connect/timeout/transient FTP error) → :class:`DeliveryError`
|
|
19
|
+
(retried); a permanent server refusal (auth failure, no-such-dir, a 5xx-class permanent FTP error) →
|
|
20
|
+
:class:`NegativeAckError` (``permanent=True``) → dead-letter.
|
|
21
|
+
|
|
22
|
+
**Source** polls ``remote_dir`` for ``pattern`` files, hands each to the pipeline handler, then — only
|
|
23
|
+
after the handler returns — moves the file to ``processed_subdir`` (or deletes it per ``after_read``).
|
|
24
|
+
A handler failure leaves the file in place to re-emit (at-least-once); an over-``max_file_bytes`` file
|
|
25
|
+
is moved to ``error_subdir`` before it's retrieved (a transport-level reject, like the File source).
|
|
26
|
+
|
|
27
|
+
**Idempotency.** Delivery is at-least-once (an upload may re-send) and a poll may re-emit a file that
|
|
28
|
+
was handled but not yet marked, so downstream consumers **must** tolerate duplicates.
|
|
29
|
+
|
|
30
|
+
The client is opened **per operation** (no shared mutable client held across an ``await``), mirroring
|
|
31
|
+
the MLLP destination's fresh-connection-per-delivery — simplest and safest under the staged pipeline's
|
|
32
|
+
concurrent workers. All blocking client I/O runs via :func:`asyncio.to_thread`.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from __future__ import annotations
|
|
36
|
+
|
|
37
|
+
import abc
|
|
38
|
+
import asyncio
|
|
39
|
+
import ftplib # nosec B402 — plain FTP is gated: cleartext credentials are refused (see _validate_common); FTPS/SFTP are the encrypted defaults
|
|
40
|
+
import io
|
|
41
|
+
import logging
|
|
42
|
+
import posixpath
|
|
43
|
+
import uuid
|
|
44
|
+
from typing import Any, Callable, TypeVar
|
|
45
|
+
|
|
46
|
+
from messagefoundry.config.models import ConnectorType, Destination, Source
|
|
47
|
+
from messagefoundry.config.settings import INSECURE_TLS_ESCAPE_ENV, insecure_tls_allowed
|
|
48
|
+
from messagefoundry.transports.base import (
|
|
49
|
+
DeliveryError,
|
|
50
|
+
DestinationConnector,
|
|
51
|
+
InboundHandler,
|
|
52
|
+
NegativeAckError,
|
|
53
|
+
SourceConnector,
|
|
54
|
+
register_destination,
|
|
55
|
+
register_source,
|
|
56
|
+
)
|
|
57
|
+
from messagefoundry.transports.file import (
|
|
58
|
+
DEFAULT_MAX_FILE_BYTES,
|
|
59
|
+
ScanRejected,
|
|
60
|
+
render_filename,
|
|
61
|
+
scan_inbound_file,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
__all__ = ["RemoteFileDestination", "RemoteFileSource"]
|
|
65
|
+
|
|
66
|
+
logger = logging.getLogger(__name__)
|
|
67
|
+
|
|
68
|
+
_PROTOCOLS = ("sftp", "ftp", "ftps")
|
|
69
|
+
|
|
70
|
+
_T = TypeVar("_T")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _redact(host: str, path: str) -> str:
|
|
74
|
+
"""``host:path`` only — never credentials, for a log line."""
|
|
75
|
+
return f"{host}:{path}"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# --- client abstraction ------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class _RemoteError(Exception):
|
|
82
|
+
"""A remote-file operation failed. ``permanent`` distinguishes a server refusal that a retry can't
|
|
83
|
+
fix (auth failure, no-such-dir, a permanent FTP 5xx) from a transient connect/IO/timeout failure.
|
|
84
|
+
|
|
85
|
+
The connector maps a transient error to :class:`DeliveryError` (retry) and a permanent one to
|
|
86
|
+
:class:`NegativeAckError` (dead-letter), so the client layer stays transport-detail-only."""
|
|
87
|
+
|
|
88
|
+
def __init__(self, message: str, *, permanent: bool) -> None:
|
|
89
|
+
super().__init__(message)
|
|
90
|
+
self.permanent = permanent
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class _RemoteClient(abc.ABC):
|
|
94
|
+
"""Connect-per-operation remote-file client. Implementations are **synchronous** (blocking I/O);
|
|
95
|
+
the connector calls them via :func:`asyncio.to_thread`. Each method opens its own connection, does
|
|
96
|
+
the operation, and closes — nothing is held across calls."""
|
|
97
|
+
|
|
98
|
+
@abc.abstractmethod
|
|
99
|
+
def list_dir(self, remote_dir: str) -> list[tuple[str, int]]:
|
|
100
|
+
"""``(name, size)`` for each regular file directly in ``remote_dir`` (no recursion)."""
|
|
101
|
+
|
|
102
|
+
@abc.abstractmethod
|
|
103
|
+
def retrieve(self, path: str) -> bytes:
|
|
104
|
+
"""The full bytes of the file at ``path``."""
|
|
105
|
+
|
|
106
|
+
@abc.abstractmethod
|
|
107
|
+
def store(self, path: str, data: bytes) -> None:
|
|
108
|
+
"""Write ``data`` to ``path`` (overwriting if it exists)."""
|
|
109
|
+
|
|
110
|
+
@abc.abstractmethod
|
|
111
|
+
def rename(self, src: str, dst: str) -> None:
|
|
112
|
+
"""Rename ``src`` to ``dst`` (atomic publish / move-to-processed)."""
|
|
113
|
+
|
|
114
|
+
@abc.abstractmethod
|
|
115
|
+
def remove(self, path: str) -> None:
|
|
116
|
+
"""Delete the file at ``path``."""
|
|
117
|
+
|
|
118
|
+
@abc.abstractmethod
|
|
119
|
+
def ensure_dir(self, remote_dir: str) -> None:
|
|
120
|
+
"""Best-effort create ``remote_dir`` (ignore "already exists")."""
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class _FtpClient(_RemoteClient):
|
|
124
|
+
"""FTP / FTPS client over the stdlib ``ftplib``. ``tls`` selects ``FTP_TLS`` (explicit TLS, with
|
|
125
|
+
``PROT P`` so the data channel is encrypted too) over plain ``FTP``."""
|
|
126
|
+
|
|
127
|
+
def __init__(self, settings: dict[str, Any], *, tls: bool) -> None:
|
|
128
|
+
self._host = str(settings["host"])
|
|
129
|
+
self._port = int(settings.get("port", 21))
|
|
130
|
+
self._user = settings.get("username")
|
|
131
|
+
self._password = settings.get("password")
|
|
132
|
+
self._tls = tls
|
|
133
|
+
self._timeout = float(settings.get("connect_timeout", 30.0))
|
|
134
|
+
|
|
135
|
+
def _connect(self) -> ftplib.FTP:
|
|
136
|
+
# B321: plain FTP only when explicitly selected; credentials over it are refused unless
|
|
137
|
+
# MEFOR_ALLOW_INSECURE_TLS is set (see _validate_common). FTPS/SFTP are the encrypted defaults.
|
|
138
|
+
if self._tls:
|
|
139
|
+
ftp: ftplib.FTP = ftplib.FTP_TLS(timeout=self._timeout)
|
|
140
|
+
else:
|
|
141
|
+
ftp = ftplib.FTP(timeout=self._timeout) # nosec B321
|
|
142
|
+
ftp.connect(self._host, self._port)
|
|
143
|
+
ftp.login(user=str(self._user or ""), passwd=str(self._password or ""))
|
|
144
|
+
if isinstance(ftp, ftplib.FTP_TLS):
|
|
145
|
+
ftp.prot_p() # encrypt the data channel, not just the control channel
|
|
146
|
+
return ftp
|
|
147
|
+
|
|
148
|
+
def list_dir(self, remote_dir: str) -> list[tuple[str, int]]:
|
|
149
|
+
return self._op(lambda ftp: self._list(ftp, remote_dir))
|
|
150
|
+
|
|
151
|
+
@staticmethod
|
|
152
|
+
def _list(ftp: ftplib.FTP, remote_dir: str) -> list[tuple[str, int]]:
|
|
153
|
+
out: list[tuple[str, int]] = []
|
|
154
|
+
# MLSD gives a reliable type + size; fall back to NLST + SIZE where the server lacks it.
|
|
155
|
+
try:
|
|
156
|
+
for name, facts in ftp.mlsd(remote_dir):
|
|
157
|
+
if name in (".", "..") or facts.get("type") != "file":
|
|
158
|
+
continue
|
|
159
|
+
out.append((name, int(facts.get("size", 0))))
|
|
160
|
+
return out
|
|
161
|
+
except (ftplib.error_perm, ftplib.error_proto):
|
|
162
|
+
pass
|
|
163
|
+
for name in ftp.nlst(remote_dir):
|
|
164
|
+
base = posixpath.basename(name)
|
|
165
|
+
if base in (".", ".."):
|
|
166
|
+
continue
|
|
167
|
+
try:
|
|
168
|
+
size = ftp.size(posixpath.join(remote_dir, base)) or 0
|
|
169
|
+
except (ftplib.error_perm, ftplib.error_proto):
|
|
170
|
+
size = 0 # a directory or an un-sizable entry; treat as 0 (oversize check skips it)
|
|
171
|
+
out.append((base, int(size)))
|
|
172
|
+
return out
|
|
173
|
+
|
|
174
|
+
def retrieve(self, path: str) -> bytes:
|
|
175
|
+
def run(ftp: ftplib.FTP) -> bytes:
|
|
176
|
+
buf = io.BytesIO()
|
|
177
|
+
ftp.retrbinary(f"RETR {path}", buf.write)
|
|
178
|
+
return buf.getvalue()
|
|
179
|
+
|
|
180
|
+
return self._op(run)
|
|
181
|
+
|
|
182
|
+
def store(self, path: str, data: bytes) -> None:
|
|
183
|
+
self._op(lambda ftp: ftp.storbinary(f"STOR {path}", io.BytesIO(data)))
|
|
184
|
+
|
|
185
|
+
def rename(self, src: str, dst: str) -> None:
|
|
186
|
+
self._op(lambda ftp: ftp.rename(src, dst))
|
|
187
|
+
|
|
188
|
+
def remove(self, path: str) -> None:
|
|
189
|
+
self._op(lambda ftp: ftp.delete(path))
|
|
190
|
+
|
|
191
|
+
def ensure_dir(self, remote_dir: str) -> None:
|
|
192
|
+
def run(ftp: ftplib.FTP) -> None:
|
|
193
|
+
try:
|
|
194
|
+
ftp.mkd(remote_dir)
|
|
195
|
+
except ftplib.error_perm:
|
|
196
|
+
pass # already exists (or no permission) — best-effort, like File's mkdir(exist_ok)
|
|
197
|
+
|
|
198
|
+
self._op(run)
|
|
199
|
+
|
|
200
|
+
def _op(self, fn: Callable[[ftplib.FTP], _T]) -> _T:
|
|
201
|
+
"""Connect, run ``fn(ftp)``, always close. Maps ``ftplib`` failures to :class:`_RemoteError`:
|
|
202
|
+
a permanent reply (``error_perm`` — auth/no-such-file/no-perm) is permanent; a connect/IO/
|
|
203
|
+
timeout/protocol error is transient."""
|
|
204
|
+
try:
|
|
205
|
+
ftp = self._connect()
|
|
206
|
+
except (
|
|
207
|
+
ftplib.error_perm
|
|
208
|
+
) as exc: # login refused — a permanent credential/permission problem
|
|
209
|
+
raise _RemoteError(f"FTP login refused: {exc}", permanent=True) from exc
|
|
210
|
+
except ftplib.all_errors as exc: # connect/timeout/protocol/OSError — transient
|
|
211
|
+
raise _RemoteError(f"FTP connect failed: {exc}", permanent=False) from exc
|
|
212
|
+
try:
|
|
213
|
+
return fn(ftp)
|
|
214
|
+
except ftplib.error_perm as exc:
|
|
215
|
+
raise _RemoteError(f"FTP rejected the operation: {exc}", permanent=True) from exc
|
|
216
|
+
except ftplib.all_errors as exc:
|
|
217
|
+
raise _RemoteError(f"FTP operation failed: {exc}", permanent=False) from exc
|
|
218
|
+
finally:
|
|
219
|
+
try:
|
|
220
|
+
ftp.quit()
|
|
221
|
+
except ftplib.all_errors:
|
|
222
|
+
ftp.close()
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _import_paramiko() -> Any:
|
|
226
|
+
"""Import the optional ``paramiko`` SSH library, raising a clear install hint if the ``[sftp]``
|
|
227
|
+
extra isn't present — so installs that never use SFTP never touch it (mirrors ``_import_aioodbc``)."""
|
|
228
|
+
try:
|
|
229
|
+
import paramiko
|
|
230
|
+
except ImportError as exc: # pragma: no cover - exercised only without the extra
|
|
231
|
+
raise RuntimeError(
|
|
232
|
+
"REMOTEFILE sftp protocol requires the 'sftp' extra: pip install 'messagefoundry[sftp]'"
|
|
233
|
+
) from exc
|
|
234
|
+
return paramiko
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
class _SftpClient(_RemoteClient):
|
|
238
|
+
"""SFTP client over paramiko. Host-key verification is ON by default (system known_hosts + an
|
|
239
|
+
optional ``known_hosts`` file, paramiko ``RejectPolicy``); an unknown key is refused unless the
|
|
240
|
+
explicit dev escape is set, in which case ``AutoAddPolicy`` is used and a warning is logged."""
|
|
241
|
+
|
|
242
|
+
def __init__(self, settings: dict[str, Any]) -> None:
|
|
243
|
+
self._host = str(settings["host"])
|
|
244
|
+
self._port = int(settings.get("port", 22))
|
|
245
|
+
self._user = settings.get("username")
|
|
246
|
+
self._password = settings.get("password")
|
|
247
|
+
self._private_key = settings.get("private_key")
|
|
248
|
+
self._key_password = settings.get("key_password")
|
|
249
|
+
self._known_hosts = settings.get("known_hosts")
|
|
250
|
+
self._timeout = float(settings.get("connect_timeout", 30.0))
|
|
251
|
+
# Fail fast at construction (build_check time): an unknown-host-key posture without the escape
|
|
252
|
+
# must never silently weaken to auto-accept. The accept-unknown policy is gated here so the
|
|
253
|
+
# connector refuses to build rather than trust-on-first-use a man-in-the-middle.
|
|
254
|
+
self._accept_unknown = insecure_tls_allowed()
|
|
255
|
+
if self._accept_unknown:
|
|
256
|
+
logger.warning(
|
|
257
|
+
"REMOTEFILE sftp %s accepts UNKNOWN host keys (AutoAddPolicy) because %s is set "
|
|
258
|
+
"— MITM-able; for a trusted-network dev/test bind only",
|
|
259
|
+
self._host,
|
|
260
|
+
INSECURE_TLS_ESCAPE_ENV,
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
def _connect(self) -> Any:
|
|
264
|
+
paramiko = _import_paramiko()
|
|
265
|
+
client = paramiko.SSHClient()
|
|
266
|
+
client.load_system_host_keys()
|
|
267
|
+
if self._known_hosts:
|
|
268
|
+
client.load_host_keys(str(self._known_hosts))
|
|
269
|
+
# RejectPolicy (default-secure): an unknown host key raises rather than being trusted. Only
|
|
270
|
+
# fall back to AutoAddPolicy behind the explicit insecure escape (set in __init__, logged).
|
|
271
|
+
client.set_missing_host_key_policy(
|
|
272
|
+
paramiko.AutoAddPolicy() if self._accept_unknown else paramiko.RejectPolicy()
|
|
273
|
+
)
|
|
274
|
+
pkey = self._load_key(paramiko)
|
|
275
|
+
client.connect(
|
|
276
|
+
hostname=self._host,
|
|
277
|
+
port=self._port,
|
|
278
|
+
username=str(self._user) if self._user else None,
|
|
279
|
+
password=str(self._password) if self._password else None,
|
|
280
|
+
pkey=pkey,
|
|
281
|
+
timeout=self._timeout,
|
|
282
|
+
allow_agent=False,
|
|
283
|
+
look_for_keys=False,
|
|
284
|
+
)
|
|
285
|
+
return client
|
|
286
|
+
|
|
287
|
+
def _load_key(self, paramiko: Any) -> Any:
|
|
288
|
+
if not self._private_key:
|
|
289
|
+
return None
|
|
290
|
+
passphrase = str(self._key_password) if self._key_password else None
|
|
291
|
+
return paramiko.RSAKey.from_private_key(
|
|
292
|
+
io.StringIO(str(self._private_key)), password=passphrase
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
def list_dir(self, remote_dir: str) -> list[tuple[str, int]]:
|
|
296
|
+
import stat as _stat
|
|
297
|
+
|
|
298
|
+
def run(sftp: Any) -> list[tuple[str, int]]:
|
|
299
|
+
out: list[tuple[str, int]] = []
|
|
300
|
+
for entry in sftp.listdir_attr(remote_dir):
|
|
301
|
+
mode = getattr(entry, "st_mode", 0) or 0
|
|
302
|
+
if _stat.S_ISREG(mode):
|
|
303
|
+
out.append((entry.filename, int(getattr(entry, "st_size", 0) or 0)))
|
|
304
|
+
return out
|
|
305
|
+
|
|
306
|
+
return self._op(run)
|
|
307
|
+
|
|
308
|
+
def retrieve(self, path: str) -> bytes:
|
|
309
|
+
def run(sftp: Any) -> bytes:
|
|
310
|
+
with sftp.open(path, "rb") as fh:
|
|
311
|
+
data: bytes = fh.read()
|
|
312
|
+
return data
|
|
313
|
+
|
|
314
|
+
return self._op(run)
|
|
315
|
+
|
|
316
|
+
def store(self, path: str, data: bytes) -> None:
|
|
317
|
+
def run(sftp: Any) -> None:
|
|
318
|
+
with sftp.open(path, "wb") as fh:
|
|
319
|
+
fh.write(data)
|
|
320
|
+
|
|
321
|
+
self._op(run)
|
|
322
|
+
|
|
323
|
+
def rename(self, src: str, dst: str) -> None:
|
|
324
|
+
self._op(lambda sftp: sftp.posix_rename(src, dst))
|
|
325
|
+
|
|
326
|
+
def remove(self, path: str) -> None:
|
|
327
|
+
self._op(lambda sftp: sftp.remove(path))
|
|
328
|
+
|
|
329
|
+
def ensure_dir(self, remote_dir: str) -> None:
|
|
330
|
+
def run(sftp: Any) -> None:
|
|
331
|
+
try:
|
|
332
|
+
sftp.stat(remote_dir)
|
|
333
|
+
except FileNotFoundError:
|
|
334
|
+
try:
|
|
335
|
+
sftp.mkdir(remote_dir)
|
|
336
|
+
except OSError:
|
|
337
|
+
pass # racing creator / no permission — best-effort
|
|
338
|
+
|
|
339
|
+
self._op(run)
|
|
340
|
+
|
|
341
|
+
def _op(self, fn: Callable[[Any], _T]) -> _T:
|
|
342
|
+
"""Connect, open an SFTP channel, run ``fn(sftp)``, always close. Maps a host-key rejection
|
|
343
|
+
to a permanent error (the operator must add the key — a retry can't fix it) and authentication
|
|
344
|
+
failure to permanent; connect/IO/timeout to transient."""
|
|
345
|
+
paramiko = _import_paramiko()
|
|
346
|
+
try:
|
|
347
|
+
client = self._connect()
|
|
348
|
+
except paramiko.AuthenticationException as exc:
|
|
349
|
+
raise _RemoteError(f"SFTP authentication failed: {exc}", permanent=True) from exc
|
|
350
|
+
except paramiko.SSHException as exc:
|
|
351
|
+
# SSHException covers an unknown/rejected host key (RejectPolicy) — a security stop the
|
|
352
|
+
# operator must resolve, so it's permanent, not a retry.
|
|
353
|
+
raise _RemoteError(f"SFTP connection rejected: {exc}", permanent=True) from exc
|
|
354
|
+
except (OSError, EOFError) as exc:
|
|
355
|
+
raise _RemoteError(f"SFTP connect failed: {exc}", permanent=False) from exc
|
|
356
|
+
try:
|
|
357
|
+
sftp = client.open_sftp()
|
|
358
|
+
try:
|
|
359
|
+
return fn(sftp)
|
|
360
|
+
finally:
|
|
361
|
+
sftp.close()
|
|
362
|
+
except FileNotFoundError as exc:
|
|
363
|
+
raise _RemoteError(f"SFTP path not found: {exc}", permanent=True) from exc
|
|
364
|
+
except paramiko.SSHException as exc:
|
|
365
|
+
raise _RemoteError(f"SFTP operation failed: {exc}", permanent=False) from exc
|
|
366
|
+
except OSError as exc:
|
|
367
|
+
raise _RemoteError(f"SFTP operation failed: {exc}", permanent=False) from exc
|
|
368
|
+
finally:
|
|
369
|
+
client.close()
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _make_client(settings: dict[str, Any]) -> _RemoteClient:
|
|
373
|
+
"""Build the protocol-appropriate client. Tests monkeypatch this (or the client classes) so no
|
|
374
|
+
real server/SSH is needed; both connectors call it per operation-batch."""
|
|
375
|
+
protocol = str(settings.get("protocol", "sftp")).lower()
|
|
376
|
+
if protocol == "sftp":
|
|
377
|
+
return _SftpClient(settings)
|
|
378
|
+
if protocol == "ftp":
|
|
379
|
+
return _FtpClient(settings, tls=False)
|
|
380
|
+
if protocol == "ftps":
|
|
381
|
+
return _FtpClient(settings, tls=True)
|
|
382
|
+
raise ValueError(f"REMOTEFILE protocol must be one of {_PROTOCOLS}, got {protocol!r}")
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _validate_common(s: dict[str, Any]) -> str:
|
|
386
|
+
"""Shared construction-time validation: required ``host``/``remote_dir``, a known ``protocol``, and
|
|
387
|
+
the cleartext-FTP credential guard. Returns the normalized protocol."""
|
|
388
|
+
for req in ("host", "remote_dir"):
|
|
389
|
+
if not s.get(req):
|
|
390
|
+
raise ValueError(f"REMOTEFILE connector requires a {req!r} setting")
|
|
391
|
+
protocol = str(s.get("protocol", "sftp")).lower()
|
|
392
|
+
if protocol not in _PROTOCOLS:
|
|
393
|
+
raise ValueError(f"REMOTEFILE protocol must be one of {_PROTOCOLS}, got {protocol!r}")
|
|
394
|
+
if protocol == "ftp" and (s.get("username") or s.get("password")):
|
|
395
|
+
# Plain FTP sends the credential in cleartext (and the body is PHI). Refuse unless the
|
|
396
|
+
# explicit dev/trusted-network escape is set, mirroring refuse_cleartext_credentials.
|
|
397
|
+
if not insecure_tls_allowed():
|
|
398
|
+
raise ValueError(
|
|
399
|
+
"REMOTEFILE plain ftp transmits credentials in CLEARTEXT; refused unless "
|
|
400
|
+
f"{INSECURE_TLS_ESCAPE_ENV} is set — use ftps (tls=True) or sftp"
|
|
401
|
+
)
|
|
402
|
+
logger.warning(
|
|
403
|
+
"REMOTEFILE %s sends credentials over CLEARTEXT ftp (no TLS)",
|
|
404
|
+
_redact(str(s["host"]), str(s.get("remote_dir", ""))),
|
|
405
|
+
)
|
|
406
|
+
return protocol
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
class RemoteFileDestination(DestinationConnector):
|
|
410
|
+
"""Upload each payload to ``remote_dir``/``filename`` over SFTP/FTP/FTPS (temp-then-rename)."""
|
|
411
|
+
|
|
412
|
+
def __init__(self, config: Destination) -> None:
|
|
413
|
+
s = config.settings
|
|
414
|
+
_validate_common(s)
|
|
415
|
+
# Constructing the SFTP client validates the host-key escape posture fail-fast (build_check).
|
|
416
|
+
self._client = _make_client(s)
|
|
417
|
+
self._settings = s
|
|
418
|
+
self._host = str(s["host"])
|
|
419
|
+
self._remote_dir = str(s["remote_dir"])
|
|
420
|
+
self._filename_template = str(s.get("filename", "{MSH-10}.hl7"))
|
|
421
|
+
self._overwrite = bool(s.get("overwrite", False))
|
|
422
|
+
self._encoding: str = s.get("encoding", "utf-8")
|
|
423
|
+
|
|
424
|
+
async def send(self, payload: str) -> None:
|
|
425
|
+
try:
|
|
426
|
+
await asyncio.to_thread(self._upload, payload)
|
|
427
|
+
except _RemoteError as exc:
|
|
428
|
+
if exc.permanent:
|
|
429
|
+
raise NegativeAckError(str(exc), code="remotefile", permanent=True) from exc
|
|
430
|
+
raise DeliveryError(str(exc)) from exc
|
|
431
|
+
|
|
432
|
+
def _upload(self, payload: str) -> None:
|
|
433
|
+
name = render_filename(self._filename_template, payload, fallback="message.hl7")
|
|
434
|
+
data = payload.encode(self._encoding)
|
|
435
|
+
self._client.ensure_dir(self._remote_dir)
|
|
436
|
+
final = posixpath.join(self._remote_dir, name)
|
|
437
|
+
if not self._overwrite:
|
|
438
|
+
final = self._unique(final)
|
|
439
|
+
# Write to a unique temp name then rename, so a poller on the far side never sees a partial
|
|
440
|
+
# file. The temp suffix is unguessable so two concurrent uploads never collide on it.
|
|
441
|
+
tmp = posixpath.join(self._remote_dir, f".{name}.{uuid.uuid4().hex}.part")
|
|
442
|
+
self._client.store(tmp, data)
|
|
443
|
+
try:
|
|
444
|
+
self._client.rename(tmp, final)
|
|
445
|
+
except _RemoteError:
|
|
446
|
+
# Publish failed — don't leave the temp behind. Best-effort cleanup, then re-raise so the
|
|
447
|
+
# delivery is classified (retry/dead-letter) by send().
|
|
448
|
+
try:
|
|
449
|
+
self._client.remove(tmp)
|
|
450
|
+
except _RemoteError:
|
|
451
|
+
logger.warning("REMOTEFILE could not remove temp %s after a failed rename", tmp)
|
|
452
|
+
raise
|
|
453
|
+
|
|
454
|
+
def _unique(self, final: str) -> str:
|
|
455
|
+
"""Return ``final`` or, if a file already exists there, ``name-1.ext``, ``name-2.ext``, …
|
|
456
|
+
Never clobbers an existing file silently (mirrors the File destination)."""
|
|
457
|
+
try:
|
|
458
|
+
existing = {n for n, _ in self._client.list_dir(self._remote_dir)}
|
|
459
|
+
except _RemoteError:
|
|
460
|
+
return final # can't list (e.g. dir not yet created) → nothing to collide with
|
|
461
|
+
base = posixpath.basename(final)
|
|
462
|
+
if base not in existing:
|
|
463
|
+
return final
|
|
464
|
+
stem, dot, ext = base.partition(".")
|
|
465
|
+
n = 1
|
|
466
|
+
while True:
|
|
467
|
+
candidate = f"{stem}-{n}{dot}{ext}"
|
|
468
|
+
if candidate not in existing:
|
|
469
|
+
return posixpath.join(self._remote_dir, candidate)
|
|
470
|
+
n += 1
|
|
471
|
+
|
|
472
|
+
async def test_connection(self) -> None:
|
|
473
|
+
# Connect + authenticate + ensure the upload dir (the destination's normal first step) — no
|
|
474
|
+
# message data written. A failure is mapped like send()'s.
|
|
475
|
+
try:
|
|
476
|
+
await asyncio.to_thread(self._client.ensure_dir, self._remote_dir)
|
|
477
|
+
except _RemoteError as exc:
|
|
478
|
+
if exc.permanent:
|
|
479
|
+
raise NegativeAckError(str(exc), code="remotefile", permanent=True) from exc
|
|
480
|
+
raise DeliveryError(str(exc)) from exc
|
|
481
|
+
|
|
482
|
+
async def aclose(self) -> None:
|
|
483
|
+
return None # connect-per-operation — nothing held open
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
class RemoteFileSource(SourceConnector):
|
|
487
|
+
"""Poll ``remote_dir`` for ``pattern`` files and feed each to the pipeline handler."""
|
|
488
|
+
|
|
489
|
+
polls_shared_resource = True # a remote dir is a shared external resource — leader-gate it
|
|
490
|
+
|
|
491
|
+
def __init__(self, config: Source) -> None:
|
|
492
|
+
s = config.settings
|
|
493
|
+
_validate_common(s)
|
|
494
|
+
self._client = _make_client(s)
|
|
495
|
+
self._host = str(s["host"])
|
|
496
|
+
self._remote_dir = str(s["remote_dir"])
|
|
497
|
+
self._pattern: str = s.get("pattern", "*.hl7")
|
|
498
|
+
self._poll_seconds: float = float(s.get("poll_seconds", 5.0))
|
|
499
|
+
self._after_read: str = s.get("after_read", "move") # "move" | "delete"
|
|
500
|
+
mfb = s.get("max_file_bytes", DEFAULT_MAX_FILE_BYTES)
|
|
501
|
+
self._max_file_bytes: int | None = int(mfb) if mfb else None
|
|
502
|
+
self._processed_dir = posixpath.join(
|
|
503
|
+
self._remote_dir, s.get("processed_subdir", ".processed")
|
|
504
|
+
)
|
|
505
|
+
self._error_dir = posixpath.join(self._remote_dir, s.get("error_subdir", ".error"))
|
|
506
|
+
self._handler: InboundHandler | None = None
|
|
507
|
+
# Leader-gate (Track B Step 4b): when set, the remote dir (a shared external resource) is
|
|
508
|
+
# listed/downloaded/moved only while the gate returns True, so in a cluster exactly one node
|
|
509
|
+
# ingests its files. None = always poll (single-node / direct callers / tests) — identical.
|
|
510
|
+
self._leader_gate: Callable[[], bool] | None = None
|
|
511
|
+
self._skipping = False # whether the last tick was gated out (for a single transition log)
|
|
512
|
+
self._stop = asyncio.Event()
|
|
513
|
+
self._task: asyncio.Task[None] | None = None
|
|
514
|
+
|
|
515
|
+
async def start(
|
|
516
|
+
self, handler: InboundHandler, *, leader_gate: Callable[[], bool] | None = None
|
|
517
|
+
) -> None:
|
|
518
|
+
self._handler = handler
|
|
519
|
+
self._leader_gate = leader_gate
|
|
520
|
+
self._stop.clear()
|
|
521
|
+
self._task = asyncio.create_task(self._run())
|
|
522
|
+
|
|
523
|
+
async def stop(self) -> None:
|
|
524
|
+
self._stop.set()
|
|
525
|
+
if self._task is not None:
|
|
526
|
+
# return_exceptions: a faulted poll task must not re-raise here — stop() runs during reload
|
|
527
|
+
# quiesce, outside its rollback (mirrors the File / DATABASE sources).
|
|
528
|
+
await asyncio.gather(self._task, return_exceptions=True)
|
|
529
|
+
self._task = None
|
|
530
|
+
|
|
531
|
+
async def test_connection(self) -> None:
|
|
532
|
+
# Connect + authenticate + list the poll dir (read-only — what the source actually does), no
|
|
533
|
+
# files moved or deleted. A failure is mapped like the delivery path's.
|
|
534
|
+
try:
|
|
535
|
+
await asyncio.to_thread(self._client.list_dir, self._remote_dir)
|
|
536
|
+
except _RemoteError as exc:
|
|
537
|
+
if exc.permanent:
|
|
538
|
+
raise NegativeAckError(str(exc), code="remotefile", permanent=True) from exc
|
|
539
|
+
raise DeliveryError(str(exc)) from exc
|
|
540
|
+
|
|
541
|
+
async def _run(self) -> None:
|
|
542
|
+
while not self._stop.is_set():
|
|
543
|
+
try:
|
|
544
|
+
if self._may_poll():
|
|
545
|
+
await self._poll_once()
|
|
546
|
+
except asyncio.CancelledError:
|
|
547
|
+
raise
|
|
548
|
+
except Exception:
|
|
549
|
+
# A poll error (connection drop, a bad pattern, a retrieve/move failure) must NOT kill
|
|
550
|
+
# the poller — it would silently stop the connection from receiving while it still
|
|
551
|
+
# reports running. Log and retry next interval (mirrors the File / DATABASE sources).
|
|
552
|
+
logger.exception(
|
|
553
|
+
"REMOTEFILE source poll failed for %s; retrying next interval",
|
|
554
|
+
_redact(self._host, self._remote_dir),
|
|
555
|
+
)
|
|
556
|
+
try:
|
|
557
|
+
await asyncio.wait_for(self._stop.wait(), self._poll_seconds)
|
|
558
|
+
except asyncio.TimeoutError:
|
|
559
|
+
pass # poll interval elapsed; poll again
|
|
560
|
+
|
|
561
|
+
def _may_poll(self) -> bool:
|
|
562
|
+
"""Whether this tick may list/retrieve/move the remote dir. False on a follower (leader-
|
|
563
|
+
gated, Step 4b): a non-leader must NOT list, download, or move/delete remote files, since
|
|
564
|
+
the dir is shared and two nodes ingesting it would duplicate intake. The loop still ticks,
|
|
565
|
+
so a node that becomes leader polls on its next tick (reactive-by-polling, no restart). When
|
|
566
|
+
the gate is None or True, behaves exactly as before. Logged once on each transition (never
|
|
567
|
+
per skipped tick — that would spam a follower's log every poll interval)."""
|
|
568
|
+
if self._leader_gate is None or self._leader_gate():
|
|
569
|
+
if self._skipping:
|
|
570
|
+
self._skipping = False
|
|
571
|
+
logger.debug(
|
|
572
|
+
"REMOTEFILE source resuming polling of %s (now leader)",
|
|
573
|
+
_redact(self._host, self._remote_dir),
|
|
574
|
+
)
|
|
575
|
+
return True
|
|
576
|
+
if not self._skipping:
|
|
577
|
+
self._skipping = True
|
|
578
|
+
logger.debug(
|
|
579
|
+
"REMOTEFILE source skipping polling of %s (not leader; another node ingests it)",
|
|
580
|
+
_redact(self._host, self._remote_dir),
|
|
581
|
+
)
|
|
582
|
+
return False
|
|
583
|
+
|
|
584
|
+
async def _poll_once(self) -> None:
|
|
585
|
+
import fnmatch
|
|
586
|
+
|
|
587
|
+
assert self._handler is not None
|
|
588
|
+
await asyncio.to_thread(self._client.ensure_dir, self._processed_dir)
|
|
589
|
+
await asyncio.to_thread(self._client.ensure_dir, self._error_dir)
|
|
590
|
+
entries = await asyncio.to_thread(self._client.list_dir, self._remote_dir)
|
|
591
|
+
for name, size in sorted(entries):
|
|
592
|
+
if self._stop.is_set():
|
|
593
|
+
break # shutting down — leave the rest for the next start (at-least-once)
|
|
594
|
+
if not fnmatch.fnmatch(name, self._pattern):
|
|
595
|
+
continue
|
|
596
|
+
path = posixpath.join(self._remote_dir, name)
|
|
597
|
+
if self._max_file_bytes is not None and size > self._max_file_bytes:
|
|
598
|
+
# Transport-level reject *before* any bytes are read — parallels the File source's
|
|
599
|
+
# oversize guard. It never became a "received message", so there's no store
|
|
600
|
+
# disposition; move it to the error dir and log it (never a silent drop).
|
|
601
|
+
logger.warning(
|
|
602
|
+
"REMOTEFILE file %s exceeds max_file_bytes (%s); routing to error dir",
|
|
603
|
+
name,
|
|
604
|
+
self._max_file_bytes,
|
|
605
|
+
)
|
|
606
|
+
await self._move(path, self._error_dir, name)
|
|
607
|
+
continue
|
|
608
|
+
try:
|
|
609
|
+
raw = await asyncio.to_thread(self._client.retrieve, path)
|
|
610
|
+
except _RemoteError as exc:
|
|
611
|
+
# Transient (locked / vanished mid-poll): leave it in place to retry next poll rather
|
|
612
|
+
# than quarantine a healthy file. Logged, never silently swallowed.
|
|
613
|
+
logger.warning(
|
|
614
|
+
"REMOTEFILE could not retrieve %s (will retry next poll): %s", name, exc
|
|
615
|
+
)
|
|
616
|
+
continue
|
|
617
|
+
try:
|
|
618
|
+
await asyncio.to_thread(scan_inbound_file, raw, name)
|
|
619
|
+
except ScanRejected as exc:
|
|
620
|
+
# A configured pre-ingest scanner (AV/ICAP/plugin) rejected the content before it
|
|
621
|
+
# entered the pipeline (ASVS 5.4.3) — the control that matters most for a remote /
|
|
622
|
+
# less-trusted drop source. Quarantine + log; like the oversize reject above it never
|
|
623
|
+
# became a "received message", so there's no store disposition.
|
|
624
|
+
logger.warning(
|
|
625
|
+
"REMOTEFILE file %s rejected by the pre-ingest scan hook (%s); routing to error dir",
|
|
626
|
+
name,
|
|
627
|
+
exc,
|
|
628
|
+
)
|
|
629
|
+
await self._move(path, self._error_dir, name)
|
|
630
|
+
continue
|
|
631
|
+
try:
|
|
632
|
+
await self._handler(raw)
|
|
633
|
+
except Exception as exc:
|
|
634
|
+
# The handler records every message-level outcome itself and returns, so an exception
|
|
635
|
+
# escaping here is an infrastructure failure (the durable store write failed). Leave the
|
|
636
|
+
# file in place so the next poll retries (at-least-once) — moving it would drop a
|
|
637
|
+
# received-but-unrecorded message (mirrors the File source's M-15).
|
|
638
|
+
logger.warning(
|
|
639
|
+
"REMOTEFILE handler failed for %s (will retry next poll): %s", name, exc
|
|
640
|
+
)
|
|
641
|
+
continue
|
|
642
|
+
await self._after_processing(path, name)
|
|
643
|
+
|
|
644
|
+
async def _after_processing(self, path: str, name: str) -> None:
|
|
645
|
+
if self._after_read == "delete":
|
|
646
|
+
try:
|
|
647
|
+
await asyncio.to_thread(self._client.remove, path)
|
|
648
|
+
except _RemoteError as exc:
|
|
649
|
+
# A processed file we can't delete will be re-read (a duplicate); surface it.
|
|
650
|
+
logger.warning("REMOTEFILE could not delete processed file %s: %s", name, exc)
|
|
651
|
+
else:
|
|
652
|
+
await self._move(path, self._processed_dir, name)
|
|
653
|
+
|
|
654
|
+
async def _move(self, path: str, dest_dir: str, name: str) -> None:
|
|
655
|
+
dst = posixpath.join(dest_dir, name)
|
|
656
|
+
try:
|
|
657
|
+
await asyncio.to_thread(self._client.rename, path, dst)
|
|
658
|
+
except _RemoteError as exc:
|
|
659
|
+
# A stuck file (locked / dest unwritable) stays and is re-read; log it.
|
|
660
|
+
logger.warning("REMOTEFILE could not move %s to %s: %s", name, dest_dir, exc)
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
register_destination(ConnectorType.REMOTEFILE, RemoteFileDestination)
|
|
664
|
+
register_source(ConnectorType.REMOTEFILE, RemoteFileSource)
|