nativegate 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.
- nativegate/__init__.py +1 -0
- nativegate/__main__.py +4 -0
- nativegate/buildinfo.py +344 -0
- nativegate/cli.py +2007 -0
- nativegate/config.py +991 -0
- nativegate/declared_invariants.py +565 -0
- nativegate/discovery.py +167 -0
- nativegate/driverbuild.py +626 -0
- nativegate/drivers/__init__.py +5 -0
- nativegate/drivers/cpp.py +616 -0
- nativegate/drivers/fortran.py +507 -0
- nativegate/generators/__init__.py +0 -0
- nativegate/generators/cmake_gen.py +101 -0
- nativegate/generators/docker_gen.py +614 -0
- nativegate/generators/error_gen.py +104 -0
- nativegate/generators/f2py_gen.py +91 -0
- nativegate/generators/gateway_gen.py +110 -0
- nativegate/generators/golden_gen.py +50 -0
- nativegate/generators/k8s_gen.py +212 -0
- nativegate/generators/mcp_gen.py +281 -0
- nativegate/generators/middleware_gen.py +717 -0
- nativegate/generators/pybind_gen.py +406 -0
- nativegate/generators/pyproject_gen.py +61 -0
- nativegate/generators/python_pkg_gen.py +1164 -0
- nativegate/generators/test_gen.py +160 -0
- nativegate/golden.py +747 -0
- nativegate/invariants.py +532 -0
- nativegate/ir.py +789 -0
- nativegate/lattice.py +350 -0
- nativegate/locking.py +216 -0
- nativegate/oracle.py +904 -0
- nativegate/parsers/__init__.py +0 -0
- nativegate/parsers/cpp.py +105 -0
- nativegate/parsers/cpp_ast.py +1652 -0
- nativegate/parsers/cpp_regex.py +812 -0
- nativegate/parsers/fixed_form.py +868 -0
- nativegate/parsers/fortran.py +157 -0
- nativegate/parsers/fortran_fparser.py +1116 -0
- nativegate/parsers/fortran_regex.py +686 -0
- nativegate/preprocess.py +335 -0
- nativegate/structural_invariants.py +762 -0
- nativegate/suggest.py +208 -0
- nativegate/templates/__init__.py +20 -0
- nativegate/templates/golden_test_template.py +248 -0
- nativegate/wire.py +438 -0
- nativegate-0.1.0.dist-info/METADATA +547 -0
- nativegate-0.1.0.dist-info/RECORD +50 -0
- nativegate-0.1.0.dist-info/WHEEL +5 -0
- nativegate-0.1.0.dist-info/entry_points.txt +3 -0
- nativegate-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,717 @@
|
|
|
1
|
+
"""The generated service's middleware: request identity, access logs, auth, limits.
|
|
2
|
+
|
|
3
|
+
Emitted as one `middleware.py` per service with a single entry point,
|
|
4
|
+
`install_middleware(app, service_name)`, called by both the standalone
|
|
5
|
+
`service.py` and the composed gateway app. Same reason `error_gen` is shared:
|
|
6
|
+
middleware attaches to an *app*, and a service router mounted into a gateway
|
|
7
|
+
runs under the gateway's app, so installing it in one place only would miss
|
|
8
|
+
the deployment more likely to face the internet.
|
|
9
|
+
|
|
10
|
+
TWO RULES THIS FILE IS BUILT AROUND
|
|
11
|
+
|
|
12
|
+
**Secrets come from the environment, never from config or the image.** API
|
|
13
|
+
keys are read from `NATIVEGATE_API_KEYS` at startup. Nothing is baked into
|
|
14
|
+
`nativegate.yaml` (which is committed) or into a layer (which is pullable by
|
|
15
|
+
anyone who can reach the registry).
|
|
16
|
+
|
|
17
|
+
**Auth fails closed.** If `api.auth: api_key` is configured and no keys are
|
|
18
|
+
present in the environment, the service **refuses to start** rather than
|
|
19
|
+
serving unauthenticated. A misconfiguration that silently disables
|
|
20
|
+
authentication is worse than one that pages someone at deploy time — the
|
|
21
|
+
first is discovered by an attacker, the second by the person deploying.
|
|
22
|
+
|
|
23
|
+
WHAT IS DELIBERATELY HONEST ABOUT THE RATE LIMITER
|
|
24
|
+
|
|
25
|
+
It is per-process. Generated images run several gunicorn workers, so a limit
|
|
26
|
+
of N is really N×`WEB_CONCURRENCY` across the service, and it resets when a
|
|
27
|
+
worker is recycled. That is useful as a crude backstop against one client
|
|
28
|
+
melting one worker; it is **not** a quota system, and real global limiting
|
|
29
|
+
belongs at the ingress, where there is one place to count. Saying so is the
|
|
30
|
+
point — an in-process counter presented as a global limit is the kind of
|
|
31
|
+
control that is trusted until the day it matters.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
from .mcp_gen import MCP_MOUNT_PATH
|
|
37
|
+
|
|
38
|
+
MIDDLEWARE_FILENAME = "middleware.py"
|
|
39
|
+
|
|
40
|
+
# Names of the env vars the generated middleware reads. Kept here so the CLI
|
|
41
|
+
# and the docs can name them without re-deriving the strings.
|
|
42
|
+
ENV_API_KEYS = "NATIVEGATE_API_KEYS"
|
|
43
|
+
ENV_RATE_LIMIT = "NATIVEGATE_RATE_LIMIT_PER_MINUTE"
|
|
44
|
+
ENV_MAX_BODY = "NATIVEGATE_MAX_REQUEST_BYTES"
|
|
45
|
+
|
|
46
|
+
# Provenance stamped onto every access-log line. Set by whatever runs the
|
|
47
|
+
# container (the console's deployer), not by the image, so they are read per
|
|
48
|
+
# call rather than frozen at import.
|
|
49
|
+
ENV_PROJECT = "NATIVEGATE_PROJECT"
|
|
50
|
+
ENV_BUILD_ID = "NATIVEGATE_BUILD_ID"
|
|
51
|
+
ENV_IMAGE_DIGEST = "NATIVEGATE_IMAGE_DIGEST"
|
|
52
|
+
|
|
53
|
+
AUTH_MODES = ("none", "api_key")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def generate_middleware_py(service_name: str, auth: str = "none") -> str:
|
|
57
|
+
"""The service's `middleware.py`.
|
|
58
|
+
|
|
59
|
+
`auth` is the *configured* mode, baked in at generate time on purpose: a
|
|
60
|
+
service generated to require authentication should not be able to have it
|
|
61
|
+
turned off by an environment variable at runtime.
|
|
62
|
+
"""
|
|
63
|
+
if auth not in AUTH_MODES:
|
|
64
|
+
raise ValueError(
|
|
65
|
+
f"Unknown auth mode {auth!r} for service {service_name!r}; "
|
|
66
|
+
f"expected one of {', '.join(AUTH_MODES)}."
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
return f'''# Generated by nativegate. Do not edit by hand — re-run `ngate generate`.
|
|
70
|
+
"""Request identity, access logging, authentication and limits for {service_name}.
|
|
71
|
+
|
|
72
|
+
Installed by both `service.py` and the gateway app via `install_middleware`.
|
|
73
|
+
See the nativegate docs on production readiness for what each layer does and,
|
|
74
|
+
just as importantly, what it does not.
|
|
75
|
+
"""
|
|
76
|
+
from __future__ import annotations
|
|
77
|
+
|
|
78
|
+
import hmac
|
|
79
|
+
import json
|
|
80
|
+
import logging
|
|
81
|
+
import os
|
|
82
|
+
import signal
|
|
83
|
+
import sys
|
|
84
|
+
import time
|
|
85
|
+
import uuid
|
|
86
|
+
from collections import deque
|
|
87
|
+
from datetime import datetime, timezone
|
|
88
|
+
from threading import Lock
|
|
89
|
+
|
|
90
|
+
from fastapi import HTTPException, Request
|
|
91
|
+
from fastapi.responses import JSONResponse
|
|
92
|
+
|
|
93
|
+
__all__ = [
|
|
94
|
+
"install_middleware",
|
|
95
|
+
"readiness",
|
|
96
|
+
"start_draining",
|
|
97
|
+
"AuthNotConfigured",
|
|
98
|
+
"current_request_id",
|
|
99
|
+
"log_call",
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
_LOG = logging.getLogger("nativegate.{service_name}")
|
|
103
|
+
_ACCESS_LOG = logging.getLogger("nativegate.{service_name}.access")
|
|
104
|
+
|
|
105
|
+
# Characters of a call's input/output payload kept per access-log line.
|
|
106
|
+
# Bounded because these lines go to stdout, get parsed by the console's
|
|
107
|
+
# tailer and stored in SQLite — an uploaded array or a large struct dump
|
|
108
|
+
# would otherwise make one call balloon the log stream and the evidence
|
|
109
|
+
# pack it feeds.
|
|
110
|
+
_PAYLOAD_LIMIT = 2000
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _truncate_payload(text: str | None) -> str | None:
|
|
114
|
+
if not text:
|
|
115
|
+
return None
|
|
116
|
+
if len(text) > _PAYLOAD_LIMIT:
|
|
117
|
+
return text[:_PAYLOAD_LIMIT] + f"... ({{len(text)}} chars total)"
|
|
118
|
+
return text
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# Bytes of an HTTP body carried forward from the capture into the log line.
|
|
122
|
+
# Larger than _PAYLOAD_LIMIT so a body that will be truncated anyway is still
|
|
123
|
+
# recognisably the right body.
|
|
124
|
+
#
|
|
125
|
+
# Honest about what this does NOT bound: a captured body is whole in memory
|
|
126
|
+
# before it gets here, because reading a request body and draining a response
|
|
127
|
+
# iterator are both all-or-nothing. What keeps that from being unbounded is
|
|
128
|
+
# the content-type gate (binary payloads — the large ones for a native
|
|
129
|
+
# service — are never read at all) and, for requests, the separate
|
|
130
|
+
# `_limit_body` layer. This limit only stops a large *JSON* body from being
|
|
131
|
+
# copied on into a log line no reader can use past _PAYLOAD_LIMIT anyway.
|
|
132
|
+
_CAPTURE_LIMIT = 8192
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _is_capturable(content_type: str, content_encoding: str = "") -> bool:
|
|
136
|
+
"""Whether a body with these headers is worth recording.
|
|
137
|
+
|
|
138
|
+
JSON only. Everything else a generated service returns is binary (packed
|
|
139
|
+
arrays, files): its bytes are noise in a log line, and skipping it is
|
|
140
|
+
what keeps the large payloads out of memory. Notably this also excludes
|
|
141
|
+
`text/event-stream`, which must not be buffered at all — see the
|
|
142
|
+
streaming note in `_access_log`.
|
|
143
|
+
|
|
144
|
+
The media type is parsed rather than substring-matched. `"json" in
|
|
145
|
+
content_type` would admit `multipart/form-data;
|
|
146
|
+
boundary=----WebKitFormBoundaryjson1a2b` — a boundary is client-chosen,
|
|
147
|
+
so a binary upload could put itself in the log as mojibake purely by
|
|
148
|
+
naming itself that, which is precisely what this gate exists to stop.
|
|
149
|
+
`+json` is honoured so structured error bodies
|
|
150
|
+
(`application/problem+json`) are captured like any other JSON.
|
|
151
|
+
|
|
152
|
+
`content_encoding` rejects a compressed body (a GZipMiddleware installed
|
|
153
|
+
inside this one, a handler setting it by hand). Its bytes are still JSON
|
|
154
|
+
by content type, but decoding them as text yields mojibake — a corrupted
|
|
155
|
+
audit record is worse than an absent one, because nothing about it looks
|
|
156
|
+
wrong.
|
|
157
|
+
"""
|
|
158
|
+
encoding = content_encoding.strip().lower()
|
|
159
|
+
if encoding and encoding != "identity":
|
|
160
|
+
return False
|
|
161
|
+
media_type = content_type.split(";", 1)[0].strip().lower()
|
|
162
|
+
return media_type == "application/json" or media_type.endswith("+json")
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _capture_body(raw: bytes) -> str | None:
|
|
166
|
+
"""The loggable string form of a body already known to be capturable.
|
|
167
|
+
|
|
168
|
+
`errors="replace"` rather than a try/except: _CAPTURE_LIMIT cuts at a
|
|
169
|
+
byte offset, which lands mid-character often enough on non-ASCII input
|
|
170
|
+
that treating it as a decode failure would silently drop whole payloads
|
|
171
|
+
for the tail of one character.
|
|
172
|
+
"""
|
|
173
|
+
if not raw:
|
|
174
|
+
return None
|
|
175
|
+
return raw[:_CAPTURE_LIMIT].decode("utf-8", errors="replace")
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _provenance() -> dict:
|
|
179
|
+
"""Which project, which build, which image produced this line.
|
|
180
|
+
|
|
181
|
+
`build_id` is emitted as an int because a consumer joining log rows against
|
|
182
|
+
build records wants a number, not the string form it arrived as; an
|
|
183
|
+
unparseable value is nulled rather than passed through half-typed.
|
|
184
|
+
"""
|
|
185
|
+
raw_build_id = os.environ.get("{ENV_BUILD_ID}") or ""
|
|
186
|
+
try:
|
|
187
|
+
build_id = int(raw_build_id)
|
|
188
|
+
except ValueError:
|
|
189
|
+
build_id = None
|
|
190
|
+
return {{
|
|
191
|
+
"project": os.environ.get("{ENV_PROJECT}") or None,
|
|
192
|
+
"build_id": build_id,
|
|
193
|
+
"image_digest": os.environ.get("{ENV_IMAGE_DIGEST}") or None,
|
|
194
|
+
}}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
class _JsonLineFormatter(logging.Formatter):
|
|
198
|
+
"""One JSON object per line on stdout — the schema the console tails.
|
|
199
|
+
|
|
200
|
+
Not the stdlib `extra=` fields verbatim: the console needs a fixed set of
|
|
201
|
+
keys present on every line (nulled, not omitted, when a field does not
|
|
202
|
+
apply to that call), so it can parse REST and MCP rows with one schema
|
|
203
|
+
rather than two.
|
|
204
|
+
|
|
205
|
+
The provenance fields (`project`, `build_id`, `image_digest`) are resolved
|
|
206
|
+
here rather than at the call sites so REST and MCP rows cannot drift, and
|
|
207
|
+
from the environment on every line rather than at import: a container's env
|
|
208
|
+
is set at `docker run`, after this module is already loaded.
|
|
209
|
+
"""
|
|
210
|
+
|
|
211
|
+
def format(self, record: logging.LogRecord) -> str:
|
|
212
|
+
return json.dumps(
|
|
213
|
+
{{
|
|
214
|
+
"ts": datetime.now(timezone.utc).isoformat(),
|
|
215
|
+
"service": getattr(record, "service", "{service_name}"),
|
|
216
|
+
"kind": getattr(record, "kind", "rest"),
|
|
217
|
+
"method": getattr(record, "method", None),
|
|
218
|
+
"path": getattr(record, "path", None),
|
|
219
|
+
"tool": getattr(record, "tool", None),
|
|
220
|
+
"status": getattr(record, "status", None),
|
|
221
|
+
"duration_ms": getattr(record, "duration_ms", None),
|
|
222
|
+
"request_id": getattr(record, "request_id", "-"),
|
|
223
|
+
"input": getattr(record, "input", None),
|
|
224
|
+
"output": getattr(record, "output", None),
|
|
225
|
+
**_provenance(),
|
|
226
|
+
}}
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class _StdoutHandler(logging.StreamHandler):
|
|
231
|
+
"""Resolves `sys.stdout` on every emit rather than once at construction.
|
|
232
|
+
|
|
233
|
+
A handler built with `logging.StreamHandler(sys.stdout)` freezes whatever
|
|
234
|
+
stream object `sys.stdout` was at import time — wrong the moment anything
|
|
235
|
+
(a test's capsys, a reloader) swaps `sys.stdout` out afterwards.
|
|
236
|
+
"""
|
|
237
|
+
|
|
238
|
+
def __init__(self) -> None:
|
|
239
|
+
super().__init__(sys.stdout)
|
|
240
|
+
|
|
241
|
+
@property
|
|
242
|
+
def stream(self):
|
|
243
|
+
return sys.stdout
|
|
244
|
+
|
|
245
|
+
@stream.setter
|
|
246
|
+
def stream(self, value) -> None:
|
|
247
|
+
pass
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _install_json_access_log() -> None:
|
|
251
|
+
# Idempotent: install_middleware and the MCP server's module-level import
|
|
252
|
+
# can both run in the same process, and a second handler would duplicate
|
|
253
|
+
# every line.
|
|
254
|
+
if any(isinstance(h, _StdoutHandler) for h in _ACCESS_LOG.handlers):
|
|
255
|
+
return
|
|
256
|
+
handler = _StdoutHandler()
|
|
257
|
+
handler.setFormatter(_JsonLineFormatter())
|
|
258
|
+
_ACCESS_LOG.addHandler(handler)
|
|
259
|
+
_ACCESS_LOG.setLevel(logging.INFO)
|
|
260
|
+
_ACCESS_LOG.propagate = False
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def log_call(
|
|
264
|
+
*,
|
|
265
|
+
kind: str,
|
|
266
|
+
request_id: str,
|
|
267
|
+
method: str | None = None,
|
|
268
|
+
path: str | None = None,
|
|
269
|
+
tool: str | None = None,
|
|
270
|
+
status: int | None = None,
|
|
271
|
+
duration_ms: float,
|
|
272
|
+
service_name: str = "{service_name}",
|
|
273
|
+
input_data: str | None = None,
|
|
274
|
+
output_data: str | None = None,
|
|
275
|
+
) -> None:
|
|
276
|
+
"""Emit one access-log line. Shared by the REST middleware and the MCP
|
|
277
|
+
server so both `kind`s go through the same formatter and schema.
|
|
278
|
+
|
|
279
|
+
`input_data`/`output_data` are already-serialised JSON strings (the
|
|
280
|
+
caller owns encoding, since a request body and an MCP tool result are
|
|
281
|
+
shaped nothing alike) and are truncated here so one oversized payload
|
|
282
|
+
cannot inflate this line past what the console's tailer and SQLite
|
|
283
|
+
column are meant to hold.
|
|
284
|
+
"""
|
|
285
|
+
_install_json_access_log()
|
|
286
|
+
_ACCESS_LOG.info(
|
|
287
|
+
"%s %s",
|
|
288
|
+
kind,
|
|
289
|
+
tool if tool is not None else path,
|
|
290
|
+
extra={{
|
|
291
|
+
"service": service_name,
|
|
292
|
+
"kind": kind,
|
|
293
|
+
"method": method,
|
|
294
|
+
"path": path,
|
|
295
|
+
"tool": tool,
|
|
296
|
+
"status": status,
|
|
297
|
+
"duration_ms": round(duration_ms, 3),
|
|
298
|
+
"request_id": request_id,
|
|
299
|
+
"input": _truncate_payload(input_data),
|
|
300
|
+
"output": _truncate_payload(output_data),
|
|
301
|
+
}},
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
MCP_MOUNT_PATH = "{MCP_MOUNT_PATH}"
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _is_mcp_transport_path(path: str) -> bool:
|
|
308
|
+
"""Whether `path` addresses the mounted MCP app.
|
|
309
|
+
|
|
310
|
+
A boundary match, not `startswith("{MCP_MOUNT_PATH}")`: a sibling route such as
|
|
311
|
+
`/mcpconfig` or `/mcp-status` shares the prefix but is ordinary REST
|
|
312
|
+
traffic, and labelling it `mcp_http` would put a row in the audit trail
|
|
313
|
+
describing a layer the request never went through.
|
|
314
|
+
"""
|
|
315
|
+
return path == MCP_MOUNT_PATH or path.startswith(MCP_MOUNT_PATH + "/")
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
# Baked in at generate time from `api.auth` in nativegate.yaml. Deliberately not
|
|
319
|
+
# an environment variable: a service generated to require authentication must
|
|
320
|
+
# not be silently downgraded by the environment it happens to start in.
|
|
321
|
+
AUTH_MODE = "{auth}"
|
|
322
|
+
|
|
323
|
+
# Paths that never require authentication. An orchestrator probing readiness
|
|
324
|
+
# has no credentials, and a liveness probe that 401s is a service that looks
|
|
325
|
+
# dead and gets restarted forever.
|
|
326
|
+
_UNAUTHENTICATED_PATHS = frozenset({{"/healthz", "/readyz"}})
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
class AuthNotConfigured(RuntimeError):
|
|
330
|
+
"""Raised at startup when auth is required but no keys are present."""
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _load_api_keys() -> list[str]:
|
|
334
|
+
raw = os.environ.get("{ENV_API_KEYS}", "")
|
|
335
|
+
return [k.strip() for k in raw.split(",") if k.strip()]
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _int_env(name: str, default: int) -> int:
|
|
339
|
+
try:
|
|
340
|
+
return int(os.environ.get(name, "") or default)
|
|
341
|
+
except ValueError:
|
|
342
|
+
_LOG.warning("%s is not an integer; falling back to %s", name, default)
|
|
343
|
+
return default
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
class _RateLimiter:
|
|
347
|
+
"""Per-process sliding window. See the module docstring for the caveat.
|
|
348
|
+
|
|
349
|
+
A deque of timestamps per client rather than a token bucket: the window is
|
|
350
|
+
exact, and for the request rates a native-compute service sees the memory
|
|
351
|
+
is irrelevant. Old entries are evicted on access, so an idle client's
|
|
352
|
+
deque costs nothing after its window passes.
|
|
353
|
+
"""
|
|
354
|
+
|
|
355
|
+
def __init__(self, per_minute: int) -> None:
|
|
356
|
+
self.per_minute = per_minute
|
|
357
|
+
self._hits: dict[str, deque[float]] = {{}}
|
|
358
|
+
self._lock = Lock()
|
|
359
|
+
|
|
360
|
+
def allow(self, client: str, now: float) -> bool:
|
|
361
|
+
if self.per_minute <= 0:
|
|
362
|
+
return True
|
|
363
|
+
cutoff = now - 60.0
|
|
364
|
+
with self._lock:
|
|
365
|
+
hits = self._hits.setdefault(client, deque())
|
|
366
|
+
while hits and hits[0] < cutoff:
|
|
367
|
+
hits.popleft()
|
|
368
|
+
if not hits:
|
|
369
|
+
# Keeps the dict from growing without bound as clients churn.
|
|
370
|
+
self._hits.pop(client, None)
|
|
371
|
+
hits = self._hits.setdefault(client, deque())
|
|
372
|
+
if len(hits) >= self.per_minute:
|
|
373
|
+
return False
|
|
374
|
+
hits.append(now)
|
|
375
|
+
return True
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def current_request_id(request: Request) -> str:
|
|
379
|
+
"""The id for this request, for handlers that want to log against it."""
|
|
380
|
+
return getattr(request.state, "request_id", "-")
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
def install_middleware(app, service_name: str = "{service_name}") -> None:
|
|
384
|
+
"""Attach every middleware layer to `app`, outermost first.
|
|
385
|
+
|
|
386
|
+
Order matters and is not arbitrary:
|
|
387
|
+
|
|
388
|
+
1. request id — so everything below it, including rejections, can be
|
|
389
|
+
correlated. A 401 with no id is an unanswerable support ticket.
|
|
390
|
+
2. access log — directly inside the request id, so it sees an id on every
|
|
391
|
+
line, and OUTSIDE the layers that reject. An access log nested inside
|
|
392
|
+
auth records only the calls that got through: a 401, a 413 and a 429
|
|
393
|
+
would each leave no row at all, which is exactly the traffic an audit
|
|
394
|
+
of a governed service most needs to see. The cost is that the recorded
|
|
395
|
+
duration includes the layers below rather than the handler alone —
|
|
396
|
+
cheap, and honest, since that is what the caller waited for.
|
|
397
|
+
3. body size — cheapest rejection, and it runs before anything reads the
|
|
398
|
+
body into memory.
|
|
399
|
+
4. auth — before the rate limiter, so an unauthenticated flood is rejected
|
|
400
|
+
without consuming the caller's quota, and so the limiter can key on the
|
|
401
|
+
authenticated identity rather than a spoofable address.
|
|
402
|
+
5. rate limit — keyed by API key when there is one, client address if not.
|
|
403
|
+
|
|
404
|
+
Starlette runs `add_middleware` in reverse registration order, so these
|
|
405
|
+
are registered bottom-up.
|
|
406
|
+
"""
|
|
407
|
+
api_keys = _load_api_keys()
|
|
408
|
+
if AUTH_MODE == "api_key" and not api_keys:
|
|
409
|
+
raise AuthNotConfigured(
|
|
410
|
+
f"{{service_name}} is configured with `api.auth: api_key` but "
|
|
411
|
+
"{ENV_API_KEYS} is empty. Refusing to start rather than serve "
|
|
412
|
+
"unauthenticated. Set it to a comma-separated list of keys, or "
|
|
413
|
+
"regenerate the service with `api.auth: none` if it is genuinely "
|
|
414
|
+
"meant to be open."
|
|
415
|
+
)
|
|
416
|
+
if AUTH_MODE == "none":
|
|
417
|
+
# Not a warning on every request — once, at startup, where an operator
|
|
418
|
+
# reading boot logs will actually see it.
|
|
419
|
+
_LOG.warning(
|
|
420
|
+
"%s is serving WITHOUT authentication (api.auth: none). Anything "
|
|
421
|
+
"that can reach the port can call the native code.", service_name
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
limiter = _RateLimiter(_int_env("{ENV_RATE_LIMIT}", 0))
|
|
425
|
+
max_body = _int_env("{ENV_MAX_BODY}", 1_048_576)
|
|
426
|
+
|
|
427
|
+
@app.middleware("http")
|
|
428
|
+
async def _rate_limit(request: Request, call_next):
|
|
429
|
+
if limiter.per_minute > 0 and request.url.path not in _UNAUTHENTICATED_PATHS:
|
|
430
|
+
client = getattr(request.state, "auth_key", None) or (
|
|
431
|
+
request.client.host if request.client else "unknown"
|
|
432
|
+
)
|
|
433
|
+
if not limiter.allow(client, time.monotonic()):
|
|
434
|
+
_LOG.warning(
|
|
435
|
+
"rate limit exceeded for %s on %s (request %s)",
|
|
436
|
+
client, request.url.path, current_request_id(request),
|
|
437
|
+
)
|
|
438
|
+
return JSONResponse(
|
|
439
|
+
status_code=429,
|
|
440
|
+
content={{
|
|
441
|
+
"error": "rate limit exceeded",
|
|
442
|
+
"limit_per_minute": limiter.per_minute,
|
|
443
|
+
"request_id": current_request_id(request),
|
|
444
|
+
}},
|
|
445
|
+
headers={{"Retry-After": "60"}},
|
|
446
|
+
)
|
|
447
|
+
return await call_next(request)
|
|
448
|
+
|
|
449
|
+
@app.middleware("http")
|
|
450
|
+
async def _authenticate(request: Request, call_next):
|
|
451
|
+
if AUTH_MODE == "api_key" and request.url.path not in _UNAUTHENTICATED_PATHS:
|
|
452
|
+
presented = request.headers.get("x-api-key") or ""
|
|
453
|
+
bearer = request.headers.get("authorization", "")
|
|
454
|
+
if not presented and bearer.lower().startswith("bearer "):
|
|
455
|
+
presented = bearer[7:].strip()
|
|
456
|
+
# compare_digest against every key: a short-circuit `in` leaks key
|
|
457
|
+
# length and position through timing. The loop is deliberately not
|
|
458
|
+
# broken early for the same reason.
|
|
459
|
+
matched = False
|
|
460
|
+
for key in api_keys:
|
|
461
|
+
if hmac.compare_digest(presented, key):
|
|
462
|
+
matched = True
|
|
463
|
+
if not matched:
|
|
464
|
+
_LOG.warning(
|
|
465
|
+
"rejected unauthenticated %s %s (request %s)",
|
|
466
|
+
request.method, request.url.path, current_request_id(request),
|
|
467
|
+
)
|
|
468
|
+
return JSONResponse(
|
|
469
|
+
status_code=401,
|
|
470
|
+
content={{
|
|
471
|
+
"error": "unauthorized",
|
|
472
|
+
"request_id": current_request_id(request),
|
|
473
|
+
}},
|
|
474
|
+
headers={{"WWW-Authenticate": "Bearer"}},
|
|
475
|
+
)
|
|
476
|
+
request.state.auth_key = presented
|
|
477
|
+
return await call_next(request)
|
|
478
|
+
|
|
479
|
+
@app.middleware("http")
|
|
480
|
+
async def _limit_body(request: Request, call_next):
|
|
481
|
+
declared = request.headers.get("content-length")
|
|
482
|
+
if declared is not None:
|
|
483
|
+
try:
|
|
484
|
+
if int(declared) > max_body:
|
|
485
|
+
return JSONResponse(
|
|
486
|
+
status_code=413,
|
|
487
|
+
content={{
|
|
488
|
+
"error": "request body too large",
|
|
489
|
+
"max_request_bytes": max_body,
|
|
490
|
+
"request_id": current_request_id(request),
|
|
491
|
+
}},
|
|
492
|
+
)
|
|
493
|
+
except ValueError:
|
|
494
|
+
return JSONResponse(
|
|
495
|
+
status_code=400,
|
|
496
|
+
content={{"error": "malformed Content-Length"}},
|
|
497
|
+
)
|
|
498
|
+
return await call_next(request)
|
|
499
|
+
|
|
500
|
+
@app.middleware("http")
|
|
501
|
+
async def _access_log(request: Request, call_next):
|
|
502
|
+
started = time.perf_counter()
|
|
503
|
+
is_mcp = _is_mcp_transport_path(request.url.path)
|
|
504
|
+
|
|
505
|
+
# Payloads are recorded for REST only. The MCP mount speaks a
|
|
506
|
+
# streaming transport, and the tool call underneath it already logs
|
|
507
|
+
# its own decoded arguments and result (mcp_server.py) — capturing
|
|
508
|
+
# here as well would both risk buffering a live stream and store the
|
|
509
|
+
# same payload twice under two `kind`s.
|
|
510
|
+
input_data = None
|
|
511
|
+
if not is_mcp and _is_capturable(
|
|
512
|
+
request.headers.get("content-type", ""),
|
|
513
|
+
request.headers.get("content-encoding", ""),
|
|
514
|
+
):
|
|
515
|
+
# Reading the body here does NOT starve the endpoint, and this
|
|
516
|
+
# layer must not try to help it along. BaseHTTPMiddleware hands
|
|
517
|
+
# dispatch a `_CachedRequest`, whose `wrapped_receive` replays a
|
|
518
|
+
# body that `.body()` already cached — downstream sees the whole
|
|
519
|
+
# request without anything from us.
|
|
520
|
+
#
|
|
521
|
+
# Re-injecting a replay onto `request._receive` (the obvious
|
|
522
|
+
# move, and wrong) additionally poisons the disconnect path: once
|
|
523
|
+
# the body is consumed, `wrapped_receive` awaits that same
|
|
524
|
+
# channel expecting only `http.disconnect`, and a replay that
|
|
525
|
+
# keeps answering `http.request` makes it raise "Unexpected
|
|
526
|
+
# message received". That crashed every body-carrying request
|
|
527
|
+
# whose handler returned a streaming response.
|
|
528
|
+
try:
|
|
529
|
+
raw = await request.body()
|
|
530
|
+
except Exception: # noqa: BLE001 - logging must never break the call
|
|
531
|
+
raw = b""
|
|
532
|
+
input_data = _capture_body(raw)
|
|
533
|
+
|
|
534
|
+
response = await call_next(request)
|
|
535
|
+
|
|
536
|
+
# Draining `body_iterator` is why this is gated on content type
|
|
537
|
+
# rather than done unconditionally: for a genuinely streaming
|
|
538
|
+
# response the loop below would not end until the stream did, turning
|
|
539
|
+
# an incremental response into a buffered one. JSON responses are
|
|
540
|
+
# already fully materialised by the time they reach here, so
|
|
541
|
+
# collecting and replaying their chunks changes nothing the client
|
|
542
|
+
# can observe.
|
|
543
|
+
output_data = None
|
|
544
|
+
body_iterator = getattr(response, "body_iterator", None)
|
|
545
|
+
if (
|
|
546
|
+
not is_mcp
|
|
547
|
+
and body_iterator is not None
|
|
548
|
+
# A Content-Length is the difference between a response that is
|
|
549
|
+
# already whole and one still being produced. Starlette sets it
|
|
550
|
+
# on a materialised body (JSONResponse and friends) and cannot
|
|
551
|
+
# set it on a StreamingResponse, whose length is unknown until
|
|
552
|
+
# the generator ends. Without this, a handler streaming
|
|
553
|
+
# newline-delimited JSON would be drained to completion before
|
|
554
|
+
# the client saw its first byte — and never released at all if
|
|
555
|
+
# the generator does not terminate.
|
|
556
|
+
#
|
|
557
|
+
# The header is taken at its word. A handler that returns a
|
|
558
|
+
# StreamingResponse and sets Content-Length on it by hand would
|
|
559
|
+
# still be drained — but that response is already malformed on
|
|
560
|
+
# its own terms (it declares a length Starlette will not
|
|
561
|
+
# enforce), and guessing around a handler that lies about its
|
|
562
|
+
# own body is not something this layer can do better than the
|
|
563
|
+
# framework.
|
|
564
|
+
and response.headers.get("content-length") is not None
|
|
565
|
+
and _is_capturable(
|
|
566
|
+
response.headers.get("content-type", ""),
|
|
567
|
+
response.headers.get("content-encoding", ""),
|
|
568
|
+
)
|
|
569
|
+
):
|
|
570
|
+
# Chunks accumulate outside the try and the replacement iterator
|
|
571
|
+
# is installed unconditionally, so no path can leave the original
|
|
572
|
+
# (now partly drained) iterator on the response.
|
|
573
|
+
#
|
|
574
|
+
# Belt and braces, not a live bug: an exception here can only
|
|
575
|
+
# come from the downstream app, which BaseHTTPMiddleware runs in
|
|
576
|
+
# a task group that re-raises it on unwind regardless of what
|
|
577
|
+
# this clause does — measured, and the client sees the same error
|
|
578
|
+
# either way. It is written this way so the guarantee survives a
|
|
579
|
+
# future where that is no longer true, and so re-raising after
|
|
580
|
+
# the collected chunks keeps the failure the response's own
|
|
581
|
+
# rather than this layer's.
|
|
582
|
+
chunks: list = []
|
|
583
|
+
failure: BaseException | None = None
|
|
584
|
+
try:
|
|
585
|
+
async for chunk in body_iterator:
|
|
586
|
+
chunks.append(chunk)
|
|
587
|
+
except Exception as exc: # noqa: BLE001 - re-raised below, not swallowed
|
|
588
|
+
failure = exc
|
|
589
|
+
|
|
590
|
+
async def _replay_response(_chunks: list = chunks, _failure=failure):
|
|
591
|
+
for chunk in _chunks:
|
|
592
|
+
yield chunk
|
|
593
|
+
if _failure is not None:
|
|
594
|
+
raise _failure
|
|
595
|
+
|
|
596
|
+
response.body_iterator = _replay_response()
|
|
597
|
+
if failure is None:
|
|
598
|
+
# Not every chunk is a body chunk. `body_stream()` yields a
|
|
599
|
+
# raw ASGI message dict for `http.response.pathsend` (a
|
|
600
|
+
# FileResponse on a server advertising that extension) —
|
|
601
|
+
# Starlette's own `stream_response` has an `isinstance(chunk,
|
|
602
|
+
# dict)` branch for it, and `bytes(a_dict)` would raise
|
|
603
|
+
# TypeError here, failing a download that had already
|
|
604
|
+
# succeeded. Non-bytes chunks are passed through to the
|
|
605
|
+
# client by the replay above and simply left out of the log.
|
|
606
|
+
#
|
|
607
|
+
# str is handled for the same defensive reason, though it is
|
|
608
|
+
# currently unreachable: the downstream response encodes at
|
|
609
|
+
# the ASGI send boundary before this layer sees it (measured).
|
|
610
|
+
output_data = _capture_body(
|
|
611
|
+
b"".join(
|
|
612
|
+
c.encode("utf-8") if isinstance(c, str) else c
|
|
613
|
+
for c in chunks
|
|
614
|
+
if isinstance(c, (bytes, bytearray, memoryview, str))
|
|
615
|
+
)
|
|
616
|
+
)
|
|
617
|
+
|
|
618
|
+
duration_ms = (time.perf_counter() - started) * 1000.0
|
|
619
|
+
# MCP transport requests are logged under their own kind rather than
|
|
620
|
+
# skipped. mcp_server.py's FastMCP hook only fires for actual tool
|
|
621
|
+
# dispatch, so skipping the whole mount left `initialize`, `tools/list`
|
|
622
|
+
# and malformed JSON-RPC bodies with no record anywhere — an audit hole
|
|
623
|
+
# in a governed service. A tools/call now yields two rows, one per
|
|
624
|
+
# layer, which is honest because they are two distinct events; the
|
|
625
|
+
# `kind` keeps them unambiguously separable, and they are deliberately
|
|
626
|
+
# NOT correlated by a contextvar (the tool call re-enters through an
|
|
627
|
+
# in-process ASGI transport and may run in a different task).
|
|
628
|
+
log_call(
|
|
629
|
+
kind="mcp_http" if _is_mcp_transport_path(request.url.path) else "rest",
|
|
630
|
+
request_id=current_request_id(request),
|
|
631
|
+
method=request.method,
|
|
632
|
+
path=request.url.path,
|
|
633
|
+
status=response.status_code,
|
|
634
|
+
duration_ms=duration_ms,
|
|
635
|
+
service_name=service_name,
|
|
636
|
+
input_data=input_data,
|
|
637
|
+
output_data=output_data,
|
|
638
|
+
)
|
|
639
|
+
response.headers["X-Response-Time-Ms"] = f"{{duration_ms:.1f}}"
|
|
640
|
+
return response
|
|
641
|
+
|
|
642
|
+
@app.middleware("http")
|
|
643
|
+
async def _request_id(request: Request, call_next):
|
|
644
|
+
# Honour an inbound id so a trace survives the hop from a gateway or
|
|
645
|
+
# service mesh, but bound its length and character set: it is attacker
|
|
646
|
+
# -controlled and it ends up in log lines.
|
|
647
|
+
inbound = request.headers.get("x-request-id", "")
|
|
648
|
+
if inbound and len(inbound) <= 64 and inbound.isascii() and inbound.isprintable():
|
|
649
|
+
request_id = inbound
|
|
650
|
+
else:
|
|
651
|
+
request_id = uuid.uuid4().hex[:12]
|
|
652
|
+
request.state.request_id = request_id
|
|
653
|
+
response = await call_next(request)
|
|
654
|
+
response.headers["X-Request-ID"] = request_id
|
|
655
|
+
return response
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
_DRAINING = False
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
def start_draining() -> None:
|
|
662
|
+
"""Stop reporting ready. Called on SIGTERM; safe to call directly."""
|
|
663
|
+
global _DRAINING
|
|
664
|
+
_DRAINING = True
|
|
665
|
+
_LOG.info("draining: /readyz will report 503 while in-flight work finishes")
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def readiness(app, service_name: str = "{service_name}"):
|
|
669
|
+
"""Register `/readyz`, distinct from `/healthz` liveness.
|
|
670
|
+
|
|
671
|
+
WHAT READINESS IS ACTUALLY FOR HERE
|
|
672
|
+
|
|
673
|
+
The obvious reading — "has the app finished starting?" — is worth nothing
|
|
674
|
+
in this service. `service.py` imports `router.py`, which imports the native
|
|
675
|
+
extension at module scope, so a worker that failed to load it never becomes
|
|
676
|
+
able to answer anything at all. Any request that gets a response at all is
|
|
677
|
+
proof the extension imported. A readiness probe asserting that would be a
|
|
678
|
+
slower liveness probe.
|
|
679
|
+
|
|
680
|
+
What readiness genuinely buys is **draining**. On a rolling deploy or a
|
|
681
|
+
scale-down, the orchestrator sends SIGTERM and gunicorn stops accepting new
|
|
682
|
+
work while finishing what is in flight. Without a readiness signal the load
|
|
683
|
+
balancer keeps routing to that worker for as long as its own health check
|
|
684
|
+
interval, and those requests are refused. Reporting 503 the moment SIGTERM
|
|
685
|
+
lands takes the process out of rotation *before* it stops accepting, which
|
|
686
|
+
is the difference between a clean deploy and a burst of 502s.
|
|
687
|
+
|
|
688
|
+
Liveness must NOT do this: a draining process is deliberately not ready but
|
|
689
|
+
perfectly alive, and a liveness probe that failed here would get it killed
|
|
690
|
+
mid-request instead of drained.
|
|
691
|
+
"""
|
|
692
|
+
|
|
693
|
+
# Chained, not replaced. gunicorn and uvicorn install their own SIGTERM
|
|
694
|
+
# handlers to run graceful shutdown; overwriting one would turn a graceful
|
|
695
|
+
# drain into an abrupt exit, which is the opposite of the goal.
|
|
696
|
+
try:
|
|
697
|
+
previous = signal.getsignal(signal.SIGTERM)
|
|
698
|
+
|
|
699
|
+
def _drain_then_delegate(signum, frame):
|
|
700
|
+
start_draining()
|
|
701
|
+
if callable(previous):
|
|
702
|
+
previous(signum, frame)
|
|
703
|
+
|
|
704
|
+
signal.signal(signal.SIGTERM, _drain_then_delegate)
|
|
705
|
+
except (ValueError, OSError):
|
|
706
|
+
# Not the main thread (some test runners, some embeddings). Draining is
|
|
707
|
+
# an optimisation, not a correctness requirement — carry on without it.
|
|
708
|
+
_LOG.debug("could not install a SIGTERM drain handler; /readyz is static")
|
|
709
|
+
|
|
710
|
+
@app.get("/readyz", tags=["health"])
|
|
711
|
+
def readyz():
|
|
712
|
+
if _DRAINING:
|
|
713
|
+
raise HTTPException(status_code=503, detail="draining")
|
|
714
|
+
return {{"status": "ready", "service": service_name}}
|
|
715
|
+
|
|
716
|
+
return readyz
|
|
717
|
+
'''
|