jasil 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.
- jasil/__init__.py +48 -0
- jasil/_core/__init__.py +5 -0
- jasil/_core/dialects.py +43 -0
- jasil/_core/identity.py +34 -0
- jasil/_core/limits.py +95 -0
- jasil/_core/network.py +199 -0
- jasil/_core/optional_deps.py +30 -0
- jasil/_core/pruning.py +58 -0
- jasil/_core/redis_clients.py +219 -0
- jasil/_core/registry.py +75 -0
- jasil/_core/sessions.py +27 -0
- jasil/_core/storage_keys.py +38 -0
- jasil/_core/threads.py +44 -0
- jasil/_core/timestamps.py +48 -0
- jasil/admin.py +124 -0
- jasil/backends/__init__.py +10 -0
- jasil/backends/clock_system.py +14 -0
- jasil/backends/events_inprocess.py +56 -0
- jasil/backends/events_redis.py +186 -0
- jasil/backends/geocoding_http.py +237 -0
- jasil/backends/lock_noop.py +17 -0
- jasil/backends/lock_pg.py +77 -0
- jasil/backends/state_memory.py +129 -0
- jasil/backends/state_redis.py +165 -0
- jasil/backends/storage_local.py +109 -0
- jasil/backends/storage_s3.py +120 -0
- jasil/capabilities.py +309 -0
- jasil/container.py +285 -0
- jasil/correlation.py +75 -0
- jasil/deps.py +67 -0
- jasil/event_log/__init__.py +1 -0
- jasil/event_log/crud.py +336 -0
- jasil/event_log/models.py +131 -0
- jasil/event_log/recorder.py +115 -0
- jasil/event_log/schema.py +101 -0
- jasil/event_versioning.py +154 -0
- jasil/events.py +148 -0
- jasil/jobs/__init__.py +10 -0
- jasil/jobs/backoff.py +51 -0
- jasil/jobs/crud.py +529 -0
- jasil/jobs/models.py +267 -0
- jasil/jobs/outbox.py +133 -0
- jasil/jobs/reconciliation.py +115 -0
- jasil/jobs/registry.py +95 -0
- jasil/jobs/relay.py +100 -0
- jasil/jobs/runner.py +185 -0
- jasil/jobs/schema.py +99 -0
- jasil/jobs/service.py +166 -0
- jasil/jobs/worker.py +89 -0
- jasil/lifecycle.py +72 -0
- jasil/migrations/__init__.py +156 -0
- jasil/migrations/env.py +64 -0
- jasil/migrations/script.py.mako +24 -0
- jasil/migrations/versions/rev0001_initial.py +242 -0
- jasil/orm.py +224 -0
- jasil/profile.py +135 -0
- jasil/providers.py +197 -0
- jasil/publisher.py +263 -0
- jasil/py.typed +0 -0
- jasil/retention.py +148 -0
- jasil/runtime.py +74 -0
- jasil/settings.py +247 -0
- jasil/subscribers.py +60 -0
- jasil/testing.py +131 -0
- jasil-0.1.0.dist-info/METADATA +221 -0
- jasil-0.1.0.dist-info/RECORD +68 -0
- jasil-0.1.0.dist-info/WHEEL +4 -0
- jasil-0.1.0.dist-info/licenses/LICENSE.md +7 -0
jasil/__init__.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""Platform substrate: deployment profile, event envelope, and providers.
|
|
2
|
+
|
|
3
|
+
Public (pure) surface — safe to import anywhere; pulls in no backends:
|
|
4
|
+
- ``DeploymentProfile`` — the deployment shape.
|
|
5
|
+
- ``Event`` / ``new_event`` — the event envelope.
|
|
6
|
+
- ``StateProvider`` / ``StorageProvider`` / ``EventBusProvider`` /
|
|
7
|
+
``LockProvider`` / ``ClockProvider`` — the capability providers.
|
|
8
|
+
|
|
9
|
+
The composition root (``Platform`` / ``build_platform``) lives in
|
|
10
|
+
``jasil.container`` and is imported explicitly where the platform is
|
|
11
|
+
built (startup, ``deps``). Keeping it out of this ``__init__`` means importing
|
|
12
|
+
the pure profile/capabilities modules never drags the concrete backends in.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from importlib.metadata import PackageNotFoundError as _PackageNotFoundError
|
|
16
|
+
from importlib.metadata import version as _version
|
|
17
|
+
|
|
18
|
+
from jasil.events import Event, new_event
|
|
19
|
+
from jasil.profile import DeploymentProfile
|
|
20
|
+
from jasil.providers import (
|
|
21
|
+
ClockProvider,
|
|
22
|
+
EventBusProvider,
|
|
23
|
+
LockProvider,
|
|
24
|
+
StateBackendUnavailableError,
|
|
25
|
+
StateProvider,
|
|
26
|
+
StorageProvider,
|
|
27
|
+
TieredFailureOutcome,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
try:
|
|
31
|
+
__version__ = _version("jasil")
|
|
32
|
+
except _PackageNotFoundError: # pragma: no cover - running from an unbuilt source tree
|
|
33
|
+
__version__ = "0.0.0+unknown"
|
|
34
|
+
|
|
35
|
+
__all__ = [
|
|
36
|
+
# Package metadata
|
|
37
|
+
"__version__",
|
|
38
|
+
"ClockProvider",
|
|
39
|
+
"DeploymentProfile",
|
|
40
|
+
"Event",
|
|
41
|
+
"EventBusProvider",
|
|
42
|
+
"LockProvider",
|
|
43
|
+
"StateBackendUnavailableError",
|
|
44
|
+
"StateProvider",
|
|
45
|
+
"StorageProvider",
|
|
46
|
+
"TieredFailureOutcome",
|
|
47
|
+
"new_event",
|
|
48
|
+
]
|
jasil/_core/__init__.py
ADDED
jasil/_core/dialects.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Database-dialect capability checks.
|
|
2
|
+
|
|
3
|
+
JASIL's tables are portable across SQLite, PostgreSQL and MySQL, but the
|
|
4
|
+
concurrency primitives are not uniformly available. Rather than testing
|
|
5
|
+
``dialect.name == "postgresql"`` at each call site — which silently downgrades
|
|
6
|
+
every other database — the checks live here.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
__all__ = ["supports_skip_locked"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def supports_skip_locked(bind: Any) -> bool:
|
|
15
|
+
"""Whether ``bind`` supports ``SELECT ... FOR UPDATE SKIP LOCKED``.
|
|
16
|
+
|
|
17
|
+
Used by the claim, relay and prune queries so concurrent workers take
|
|
18
|
+
disjoint batches instead of contending. Where it is unavailable the caller
|
|
19
|
+
falls back to a plain select, which stays *correct* for a single worker but
|
|
20
|
+
offers no protection against two workers claiming the same row — so this
|
|
21
|
+
must not report a false positive.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
bind: The session's bind (an ``Engine`` or ``Connection``), or ``None``.
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
True when the clause is supported. SQLite always returns False: it has
|
|
28
|
+
no row-level locking, and a single writer makes the clause unnecessary.
|
|
29
|
+
"""
|
|
30
|
+
if bind is None:
|
|
31
|
+
return False
|
|
32
|
+
dialect = bind.dialect
|
|
33
|
+
if dialect.name == "postgresql":
|
|
34
|
+
return True
|
|
35
|
+
if dialect.name == "mysql":
|
|
36
|
+
# Populated on first connect; treat "unknown" as unsupported.
|
|
37
|
+
version = getattr(dialect, "server_version_info", None)
|
|
38
|
+
if not version:
|
|
39
|
+
return False
|
|
40
|
+
if getattr(dialect, "is_mariadb", False):
|
|
41
|
+
return version >= (10, 6)
|
|
42
|
+
return version >= (8, 0, 1)
|
|
43
|
+
return False
|
jasil/_core/identity.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Process identity for competing-consumer coordination.
|
|
2
|
+
|
|
3
|
+
A single helper so the durable-job worker and the Redis Streams consumer derive
|
|
4
|
+
their identifier the same way. The value distinguishes competing consumers
|
|
5
|
+
across replicas in logs, lease holders (``processing_jobs.locked_by``), and
|
|
6
|
+
Redis pending-entry lists.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import os
|
|
11
|
+
import socket
|
|
12
|
+
|
|
13
|
+
from jasil._core.limits import MAX_WORKER_ID_LENGTH
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def process_identity() -> str:
|
|
17
|
+
"""Return a stable per-process identifier that fits the lease column.
|
|
18
|
+
|
|
19
|
+
Uniqueness is the point: this value is what ``claim_jobs`` compares against
|
|
20
|
+
to decide whether *this* worker won a row, so two live processes sharing one
|
|
21
|
+
identity would run the same job twice. A long hostname therefore cannot
|
|
22
|
+
simply be clipped — the truncated form carries a digest of the full identity
|
|
23
|
+
instead, which stays distinct where a shared prefix would not.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
``"{hostname}-{pid}"`` — unique per process, stable for its lifetime — or
|
|
27
|
+
a digest-suffixed form of it when that would exceed
|
|
28
|
+
:data:`~jasil._core.limits.MAX_WORKER_ID_LENGTH`.
|
|
29
|
+
"""
|
|
30
|
+
identity = f"{socket.gethostname()}-{os.getpid()}"
|
|
31
|
+
if len(identity) <= MAX_WORKER_ID_LENGTH:
|
|
32
|
+
return identity
|
|
33
|
+
digest = hashlib.blake2s(identity.encode(), digest_size=8).hexdigest()
|
|
34
|
+
return f"{identity[: MAX_WORKER_ID_LENGTH - len(digest) - 1]}-{digest}"
|
jasil/_core/limits.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Bounds on the values JASIL persists.
|
|
2
|
+
|
|
3
|
+
Two shapes of bound, because the values differ in kind:
|
|
4
|
+
|
|
5
|
+
* :func:`check_length` **raises**. The envelope's ``event_id`` / ``event_type`` /
|
|
6
|
+
``source`` and a durable ``subscriber_id`` are written by the producing code,
|
|
7
|
+
never by a user, so an over-long one is a bug to fix rather than a value to
|
|
8
|
+
clip — a truncated ``event_type`` routes to the wrong subscribers, or to none.
|
|
9
|
+
* :func:`fit_length` **truncates**, leaving a marker. Failure text, the joined
|
|
10
|
+
subscriber list, and a worker's identity are derived and diagnostic, so
|
|
11
|
+
clipping them loses nothing that matters while refusing would lose the record
|
|
12
|
+
itself. The marker is what stops a reader assuming the value is complete.
|
|
13
|
+
|
|
14
|
+
Either way the bound is applied where the value enters the system, so an
|
|
15
|
+
over-long value is one clear outcome instead of a truncation error on PostgreSQL
|
|
16
|
+
and MySQL, silent acceptance on SQLite, and — because the publish seam swallows
|
|
17
|
+
delivery failures — a lost event on all three.
|
|
18
|
+
|
|
19
|
+
**Every constant here is imported by the model declaring the column it bounds**,
|
|
20
|
+
so a width and the code protecting it cannot drift apart. The one pair that was
|
|
21
|
+
allowed to drift is what produced the bug recorded on
|
|
22
|
+
:data:`MAX_HANDLER_NAME_LENGTH`.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"MAX_HANDLER_NAME_LENGTH",
|
|
27
|
+
"MAX_STORED_ERROR_LENGTH",
|
|
28
|
+
"MAX_WORKER_ID_LENGTH",
|
|
29
|
+
"check_length",
|
|
30
|
+
"fit_length",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
#: Cap on the failure text written to ``event_log.error_message`` and
|
|
34
|
+
#: ``processing_jobs.last_error``. Both are unbounded ``Text`` columns, so this
|
|
35
|
+
#: exists to stop one pathological exception (a driver dumping a whole query, a
|
|
36
|
+
#: deeply nested cause chain) from bloating every retry's row.
|
|
37
|
+
MAX_STORED_ERROR_LENGTH = 4000
|
|
38
|
+
|
|
39
|
+
#: Width of ``event_log.handler_name``, which holds the comma-joined list of every
|
|
40
|
+
#: subscriber that ran for one event — so its length grows with the subscriber
|
|
41
|
+
#: count and is unbounded from the writing layer's point of view. Overflowing it
|
|
42
|
+
#: made PostgreSQL reject the whole UPDATE, and because event-log writes are
|
|
43
|
+
#: deliberately best-effort (swallowed by the recorder so observability never
|
|
44
|
+
#: breaks processing) the failure was silent: the handlers had already run, so
|
|
45
|
+
#: the work completed while the row stayed ``published`` forever.
|
|
46
|
+
MAX_HANDLER_NAME_LENGTH = 500
|
|
47
|
+
|
|
48
|
+
#: Width of ``event_log.worker_id`` and ``processing_jobs.locked_by``, both of
|
|
49
|
+
#: which hold a process identity. That value derives from the machine's hostname
|
|
50
|
+
#: (see :func:`jasil._core.identity.process_identity`), so a deployment cannot shorten it
|
|
51
|
+
#: and the library has to guarantee the fit itself.
|
|
52
|
+
MAX_WORKER_ID_LENGTH = 100
|
|
53
|
+
|
|
54
|
+
_TRUNCATION_MARKER = "..."
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def check_length(value: str, *, field: str, limit: int) -> None:
|
|
58
|
+
"""Raise when ``value`` would not fit the ``limit``-character column for ``field``.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
value: The identifier to check.
|
|
62
|
+
field: Its name, used in the error message.
|
|
63
|
+
limit: The character limit of the column it is stored in.
|
|
64
|
+
|
|
65
|
+
Raises:
|
|
66
|
+
ValueError: When ``value`` is longer than ``limit``. These identifiers are
|
|
67
|
+
written by the producing code, never by users, so an over-long one is
|
|
68
|
+
a bug to fix rather than a value to clip: a truncated ``event_type``
|
|
69
|
+
would route to the wrong subscribers, or to none at all.
|
|
70
|
+
"""
|
|
71
|
+
if len(value) > limit:
|
|
72
|
+
raise ValueError(
|
|
73
|
+
f"{field} is {len(value)} characters, which exceeds the {limit}-character limit "
|
|
74
|
+
f"and would be rejected or truncated on write: {value[:limit]!r}..."
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def fit_length(value: str | None, limit: int) -> str | None:
|
|
79
|
+
"""Clamp ``value`` to ``limit`` characters, marking it when it had to be cut.
|
|
80
|
+
|
|
81
|
+
For the derived, diagnostic values :func:`check_length` would be the wrong
|
|
82
|
+
tool for: refusing one discards the very record being written.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
value: The text about to be stored, or ``None``.
|
|
86
|
+
limit: The character limit of the column it is stored in.
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
``value`` unchanged when it is ``None`` or already fits, otherwise
|
|
90
|
+
exactly ``limit`` characters ending in ``...``, so a reader can tell the
|
|
91
|
+
value was cut rather than assume it is complete.
|
|
92
|
+
"""
|
|
93
|
+
if value is None or len(value) <= limit:
|
|
94
|
+
return value
|
|
95
|
+
return value[: limit - len(_TRUNCATION_MARKER)] + _TRUNCATION_MARKER
|
jasil/_core/network.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""SSRF guard for operator-configured outbound hosts.
|
|
2
|
+
|
|
3
|
+
Vendored from a host application's network helpers, trimmed to the outbound
|
|
4
|
+
half. The inbound half (proxy-aware client-IP extraction, trusted-proxy
|
|
5
|
+
resolution) is a web-framework concern and deliberately did not come across:
|
|
6
|
+
JASIL serves no requests, and keeping it out is what lets this module stay free
|
|
7
|
+
of ``fastapi``.
|
|
8
|
+
|
|
9
|
+
The allowlist is passed in rather than read from a global, so the host owns the
|
|
10
|
+
policy and the guard stays a pure function.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import ipaddress
|
|
14
|
+
import logging
|
|
15
|
+
import re
|
|
16
|
+
import socket
|
|
17
|
+
from collections.abc import Sequence
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
# RFC 1123 hostname syntax: labels of 1-63 alphanumeric/hyphen characters,
|
|
22
|
+
# separated by dots. Hyphens may not start or end a label.
|
|
23
|
+
_HOSTNAME_RE = re.compile(
|
|
24
|
+
r"^(?:[a-z0-9](?:[a-z0-9\-]{0,61}[a-z0-9])?)" # first label
|
|
25
|
+
r"(?:\.(?:[a-z0-9](?:[a-z0-9\-]{0,61}[a-z0-9])?))*$", # more
|
|
26
|
+
re.IGNORECASE,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
# Reasons a destination is refused. Phrased to read correctly after a host value.
|
|
30
|
+
_UNRESOLVABLE = "hostname could not be resolved"
|
|
31
|
+
_UNPARSEABLE = "resolves to an unparseable address"
|
|
32
|
+
_NON_PUBLIC = "resolves to a non-public address"
|
|
33
|
+
_NOT_AN_AUTHORITY = "is not a bare host[:port] authority"
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _is_valid_hostname(value: str) -> bool:
|
|
37
|
+
"""Return True when ``value`` is a syntactically valid RFC 1123 hostname.
|
|
38
|
+
|
|
39
|
+
Rejects values carrying URL schemes, ports, or other non-hostname characters
|
|
40
|
+
(e.g. ``caddy:8080`` or ``http://caddy``).
|
|
41
|
+
|
|
42
|
+
Args:
|
|
43
|
+
value: Candidate hostname to validate.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
True when ``value`` conforms to RFC 1123 hostname syntax.
|
|
47
|
+
"""
|
|
48
|
+
return len(value) <= 253 and _HOSTNAME_RE.match(value) is not None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _is_private_or_reserved(addr: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
|
|
52
|
+
"""Return True if ``addr`` belongs to any non-routable range.
|
|
53
|
+
|
|
54
|
+
Combines every "do not dial" predicate ``ipaddress`` exposes: private
|
|
55
|
+
(RFC1918, fc00::/7), loopback, link-local, multicast, unspecified, and
|
|
56
|
+
reserved. Any of these would let an attacker pivot to internal
|
|
57
|
+
infrastructure or a cloud metadata service.
|
|
58
|
+
"""
|
|
59
|
+
return (
|
|
60
|
+
addr.is_private
|
|
61
|
+
or addr.is_loopback
|
|
62
|
+
or addr.is_link_local
|
|
63
|
+
or addr.is_multicast
|
|
64
|
+
or addr.is_unspecified
|
|
65
|
+
or addr.is_reserved
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _split_allowlist(
|
|
70
|
+
allowed_hosts: Sequence[str],
|
|
71
|
+
) -> tuple[frozenset[str], tuple[ipaddress.IPv4Network | ipaddress.IPv6Network, ...]]:
|
|
72
|
+
"""Classify allowlist entries into hostnames and IP networks."""
|
|
73
|
+
hosts: set[str] = set()
|
|
74
|
+
networks: list[ipaddress.IPv4Network | ipaddress.IPv6Network] = []
|
|
75
|
+
for entry in allowed_hosts:
|
|
76
|
+
try:
|
|
77
|
+
networks.append(ipaddress.ip_network(entry, strict=False))
|
|
78
|
+
except ValueError:
|
|
79
|
+
hosts.add(entry.lower())
|
|
80
|
+
return frozenset(hosts), tuple(networks)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _address_rejection_reason(
|
|
84
|
+
hostname: str,
|
|
85
|
+
*,
|
|
86
|
+
allowed_hosts: Sequence[str],
|
|
87
|
+
purpose: str | None = None,
|
|
88
|
+
) -> str | None:
|
|
89
|
+
"""Return why ``hostname`` must not be dialed, or None when every address is safe.
|
|
90
|
+
|
|
91
|
+
Resolves every A/AAAA record and requires all of them to be public unicast. A
|
|
92
|
+
single private/loopback/link-local answer rejects the host — this defends
|
|
93
|
+
against DNS rebinding, where an attacker-controlled name returns a public IP
|
|
94
|
+
on the first lookup and a private IP on the next.
|
|
95
|
+
|
|
96
|
+
Args:
|
|
97
|
+
hostname: The hostname to resolve, without a port.
|
|
98
|
+
allowed_hosts: Hostnames and CIDRs exempt from the address denylist.
|
|
99
|
+
purpose: Optional short tag identifying the outbound call, used only for
|
|
100
|
+
audit logging.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
A reason string, or ``None`` when the host may be dialed.
|
|
104
|
+
"""
|
|
105
|
+
try:
|
|
106
|
+
infos = socket.getaddrinfo(hostname, None)
|
|
107
|
+
except socket.gaierror:
|
|
108
|
+
return _UNRESOLVABLE
|
|
109
|
+
|
|
110
|
+
exempt_hostnames, exempt_networks = _split_allowlist(allowed_hosts)
|
|
111
|
+
for info in infos:
|
|
112
|
+
ip_text = info[4][0]
|
|
113
|
+
try:
|
|
114
|
+
addr = ipaddress.ip_address(ip_text)
|
|
115
|
+
except ValueError:
|
|
116
|
+
# Defensive: a resolver answer we cannot parse is treated as unsafe.
|
|
117
|
+
return _UNPARSEABLE
|
|
118
|
+
if not _is_private_or_reserved(addr):
|
|
119
|
+
continue
|
|
120
|
+
# Audit trail: every allowlisted private destination is logged so
|
|
121
|
+
# operators can review what the exception is being used for. A hostname
|
|
122
|
+
# entry is the broader of the two, and says so.
|
|
123
|
+
if hostname.lower() in exempt_hostnames:
|
|
124
|
+
logger.warning(
|
|
125
|
+
"SSRF allowlist hit: dialing private address %s for host %s (purpose=%s). That entry is a "
|
|
126
|
+
"hostname, so every address the name resolves to is exempt \u2014 including any it starts "
|
|
127
|
+
"returning later. Prefer a CIDR entry, which cannot widen.",
|
|
128
|
+
ip_text,
|
|
129
|
+
hostname,
|
|
130
|
+
purpose or "unspecified",
|
|
131
|
+
)
|
|
132
|
+
elif any(addr in network for network in exempt_networks):
|
|
133
|
+
logger.info(
|
|
134
|
+
"SSRF allowlist hit: dialing private address %s for host %s (purpose=%s)",
|
|
135
|
+
ip_text,
|
|
136
|
+
hostname,
|
|
137
|
+
purpose or "unspecified",
|
|
138
|
+
)
|
|
139
|
+
else:
|
|
140
|
+
return _NON_PUBLIC
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def host_rejection_reason(
|
|
145
|
+
host: str | None,
|
|
146
|
+
*,
|
|
147
|
+
allowed_hosts: Sequence[str] = (),
|
|
148
|
+
purpose: str | None = None,
|
|
149
|
+
) -> str | None:
|
|
150
|
+
"""Return why an operator-configured ``host[:port]`` must not be dialed, or None.
|
|
151
|
+
|
|
152
|
+
Returns a reason rather than raising, because callers handed a host from
|
|
153
|
+
configuration must *degrade* (disable the feature) rather than fail.
|
|
154
|
+
|
|
155
|
+
The value must be a plain ``host[:port]``. One carrying a scheme, path, or
|
|
156
|
+
credentials would otherwise be interpolated into a URL by the caller and
|
|
157
|
+
silently redirect the request elsewhere — ``"evil.example.com/x"`` becomes
|
|
158
|
+
``"https://evil.example.com/x/reverse"``, whose hostname check passes.
|
|
159
|
+
|
|
160
|
+
**This is a time-of-check guard, and callers use it once.** The geocoding
|
|
161
|
+
backend validates its host while the composition root assembles the platform,
|
|
162
|
+
then dials the resulting URL for the life of the process — so a name that
|
|
163
|
+
resolves publicly at startup and privately afterwards is not caught. What is
|
|
164
|
+
caught is the case that matters for an operator-supplied setting: a host that
|
|
165
|
+
was already internal when it was configured. Refusing redirects (which the
|
|
166
|
+
geocoding backend does) closes the other easy pivot. Full TOCTOU safety would
|
|
167
|
+
mean pinning the resolved public IP and dialing it with the original Host
|
|
168
|
+
header on every request.
|
|
169
|
+
|
|
170
|
+
Resolution is also unbounded: :func:`socket.getaddrinfo` takes no timeout, so
|
|
171
|
+
an unresponsive resolver stalls whoever called this. At startup that shows up
|
|
172
|
+
as a slow boot rather than a hung request.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
host: The configured host authority, or ``None``.
|
|
176
|
+
allowed_hosts: Hostnames and CIDRs exempt from the address denylist, for
|
|
177
|
+
reaching a self-hosted service on a private network. An entry that is
|
|
178
|
+
a *hostname* exempts every address it resolves to — including a cloud
|
|
179
|
+
metadata endpoint — so prefer a CIDR where you can; a hostname
|
|
180
|
+
exemption is logged at ``WARNING`` each time it is taken.
|
|
181
|
+
purpose: Optional short tag identifying the outbound call, used only for
|
|
182
|
+
audit logging.
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
A short human-readable reason, or ``None`` when the host may be dialed.
|
|
186
|
+
"""
|
|
187
|
+
if host is None:
|
|
188
|
+
return _NOT_AN_AUTHORITY
|
|
189
|
+
|
|
190
|
+
hostname, separator, port = host.rpartition(":")
|
|
191
|
+
if not separator:
|
|
192
|
+
hostname = host
|
|
193
|
+
elif not (port.isdigit() and len(port) <= 5):
|
|
194
|
+
return _NOT_AN_AUTHORITY
|
|
195
|
+
|
|
196
|
+
if not _is_valid_hostname(hostname):
|
|
197
|
+
return _NOT_AN_AUTHORITY
|
|
198
|
+
|
|
199
|
+
return _address_rejection_reason(hostname, allowed_hosts=allowed_hosts, purpose=purpose)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Guard for features behind an optional extra.
|
|
2
|
+
|
|
3
|
+
Turns a missing optional dependency into one clear message naming the extra to
|
|
4
|
+
install, instead of a bare ``ModuleNotFoundError`` from somewhere deep in the
|
|
5
|
+
call stack.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
__all__ = ["require"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def require(module: Any, *, package: str, extra: str, feature: str) -> Any:
|
|
14
|
+
"""Return ``module``, or raise explaining which extra provides it.
|
|
15
|
+
|
|
16
|
+
Args:
|
|
17
|
+
module: The lazily imported module, or ``None`` when the import failed.
|
|
18
|
+
package: Distribution name, e.g. ``alembic``.
|
|
19
|
+
extra: The JASIL extra providing it, e.g. ``migrations``.
|
|
20
|
+
feature: Human-readable feature name for the message.
|
|
21
|
+
|
|
22
|
+
Raises:
|
|
23
|
+
ImportError: When ``module`` is ``None``.
|
|
24
|
+
"""
|
|
25
|
+
if module is None:
|
|
26
|
+
raise ImportError(
|
|
27
|
+
f"{feature} requires the '{package}' package, which is not installed. "
|
|
28
|
+
f"Install it with: pip install 'jasil[{extra}]'"
|
|
29
|
+
)
|
|
30
|
+
return module
|
jasil/_core/pruning.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
"""Bounded batch deletes shared by the substrate's prunable tables.
|
|
2
|
+
|
|
3
|
+
Every table :mod:`jasil.retention` prunes (``event_log``, relayed
|
|
4
|
+
``event_outbox`` rows, ``completed`` ``processing_jobs``) is deleted the
|
|
5
|
+
same way, and only the model and the filter differ. The batching is what
|
|
6
|
+
keeps each delete transaction short, so a prune pass never holds locks on
|
|
7
|
+
a hot table long enough to block the relay or the worker.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
from sqlalchemy import ColumnExpressionArgument, delete, select
|
|
13
|
+
from sqlalchemy.orm import Session
|
|
14
|
+
|
|
15
|
+
from jasil._core.dialects import supports_skip_locked
|
|
16
|
+
|
|
17
|
+
# Rows deleted per batch when pruning; bounded so each delete transaction
|
|
18
|
+
# stays short.
|
|
19
|
+
PRUNE_BATCH_SIZE = 1000
|
|
20
|
+
# Safety cap on batches per pass so a pathological backlog cannot spin
|
|
21
|
+
# forever; the next scheduled pass continues where this one left off.
|
|
22
|
+
PRUNE_MAX_BATCHES = 10_000
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def bounded_delete(
|
|
26
|
+
model: type[Any],
|
|
27
|
+
*conditions: ColumnExpressionArgument[bool],
|
|
28
|
+
db: Session,
|
|
29
|
+
batch_size: int = PRUNE_BATCH_SIZE,
|
|
30
|
+
) -> int:
|
|
31
|
+
"""
|
|
32
|
+
Delete the rows matching ``conditions`` in bounded, committed batches.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
model: Mapped class to delete from; must expose an ``id`` column.
|
|
36
|
+
*conditions: Filters selecting the rows that are safe to prune.
|
|
37
|
+
db: Active database session.
|
|
38
|
+
batch_size: Maximum rows deleted per batch.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
The total number of rows deleted.
|
|
42
|
+
"""
|
|
43
|
+
total = 0
|
|
44
|
+
for _ in range(PRUNE_MAX_BATCHES):
|
|
45
|
+
id_stmt = select(model.id).where(*conditions).limit(batch_size)
|
|
46
|
+
if supports_skip_locked(db.bind): # pragma: no cover - server-side locking, not exercised on SQLite
|
|
47
|
+
# Concurrent prunes step over each other's claimed page rather
|
|
48
|
+
# than blocking on it.
|
|
49
|
+
id_stmt = id_stmt.with_for_update(skip_locked=True)
|
|
50
|
+
ids = list(db.execute(id_stmt).scalars().all())
|
|
51
|
+
if not ids:
|
|
52
|
+
break
|
|
53
|
+
db.execute(delete(model).where(model.id.in_(ids)))
|
|
54
|
+
db.commit()
|
|
55
|
+
total += len(ids)
|
|
56
|
+
if len(ids) < batch_size:
|
|
57
|
+
break
|
|
58
|
+
return total
|