leblob 0.4.0.dev1__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.
leblob/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """leblob: an S3-compatible object store."""
leblob/asgi.py ADDED
@@ -0,0 +1,376 @@
1
+ """The ASGI application: the one place where S3 errors become HTTP responses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import json
7
+ import logging
8
+ import os
9
+ import secrets
10
+ import time
11
+ from contextlib import aclosing, suppress
12
+ from dataclasses import replace
13
+ from pathlib import Path
14
+ from typing import TYPE_CHECKING
15
+
16
+ from leblob import handlers, maintenance, meta, metrics
17
+ from leblob.blobstore import BlobStore, VolumeError, open_store
18
+ from leblob.handlers import Context, Request, Response
19
+ from leblob.meta import Database
20
+ from leblob.model import HttpRequest
21
+ from leblob.s3 import access, errors, routing, sigv4, xml
22
+ from leblob.s3.errors import S3Error
23
+ from leblob.s3.routing import BucketRoute, ObjectRoute, Operation, ServiceRoute
24
+ from leblob.streams import ClientDisconnectedError
25
+
26
+ if TYPE_CHECKING:
27
+ from collections.abc import AsyncGenerator, Awaitable, Callable, Mapping
28
+ from typing import Any
29
+
30
+ type Scope = Mapping[str, Any]
31
+ type Message = Mapping[str, Any]
32
+ type Receive = Callable[[], Awaitable[Message]]
33
+ type Send = Callable[[Message], Awaitable[None]]
34
+ type App = Callable[[Scope, Receive, Send], Awaitable[None]]
35
+
36
+ ENV_DATA_DIR = "LEBLOB_DATA_DIR"
37
+ ENV_BASE_DOMAIN = "LEBLOB_BASE_DOMAIN"
38
+ ENV_LIFECYCLE_DAY_SECONDS = "LEBLOB_LIFECYCLE_DAY_SECONDS"
39
+ ENV_SSE_OVER_HTTP = "LEBLOB_ALLOW_SSE_C_OVER_HTTP"
40
+ META_FILE = "meta.db"
41
+
42
+ _CONNECTION_CLOSE = ("connection", "close")
43
+ _FIRST_ERROR_STATUS = 400
44
+ _FIRST_SERVER_ERROR_STATUS = 500
45
+ _STATUS_CLIENT_GONE = 499
46
+ _STATUSES_WITHOUT_BODY = frozenset({204, 304})
47
+ _METRICS_CONTENT_TYPE = "text/plain; version=0.0.4; charset=utf-8"
48
+
49
+ logger = logging.getLogger("leblob.access")
50
+
51
+
52
+ def create_app_from_env() -> App:
53
+ """Build the application in a worker process, from what `serve` set up."""
54
+ # Here and not in `serve`: each worker is its own process, with its own logging.
55
+ logging.basicConfig(level=logging.INFO, format="%(message)s")
56
+ data_dir = Path(os.environ[ENV_DATA_DIR])
57
+ database = data_dir / META_FILE
58
+ context = Context(
59
+ Database(database),
60
+ open_blobs_for_worker(data_dir, database),
61
+ data_dir,
62
+ int(os.environ[ENV_LIFECYCLE_DAY_SECONDS]) * 1000,
63
+ ENV_SSE_OVER_HTTP in os.environ,
64
+ )
65
+ return create_app(context, os.environ.get(ENV_BASE_DOMAIN))
66
+
67
+
68
+ def open_blobs_for_worker(data_dir: Path, database: Path) -> BlobStore:
69
+ """Open what can be opened, and never refuse to start.
70
+
71
+ `serve` has already refused a store that is wrong. What fails here is passing, such
72
+ as a full disk, and a worker that exits over it is started again and again, while
73
+ nothing answers clients. One with no volumes answers them with errors, until
74
+ maintenance finds it can open them after all.
75
+ """
76
+ try:
77
+ records, copies = meta.load_store_settings(database)
78
+ return open_store(data_dir, records, copies, allow_degraded=True)
79
+ except (meta.MetadataUnavailableError, VolumeError):
80
+ logger.exception("Starting with no volumes for now.")
81
+ return BlobStore({})
82
+
83
+
84
+ def create_app(context: Context, base_domain: str | None) -> App:
85
+ async def app(scope: Scope, receive: Receive, send: Send) -> None:
86
+ if scope["type"] == "lifespan":
87
+ await _run_lifespan(context, receive, send)
88
+ else:
89
+ await _handle_http(context, base_domain, scope, receive, send)
90
+
91
+ return app
92
+
93
+
94
+ async def _run_lifespan(context: Context, receive: Receive, send: Send) -> None:
95
+ await receive()
96
+ tasks = [
97
+ asyncio.create_task(job)
98
+ for job in (
99
+ maintenance.run_forever(context.db, context.blobs, context.data_dir),
100
+ maintenance.run_repairs(context.db, context.blobs),
101
+ maintenance.run_scrub(context.db, context.blobs),
102
+ maintenance.run_lifecycle(context.db, context.lifecycle_day_ms),
103
+ metrics.run_flusher(context.data_dir),
104
+ )
105
+ ]
106
+ await send({"type": "lifespan.startup.complete"})
107
+ await receive()
108
+ for task in tasks:
109
+ task.cancel()
110
+ for task in tasks:
111
+ with suppress(asyncio.CancelledError):
112
+ await task
113
+ context.db.close()
114
+ await send({"type": "lifespan.shutdown.complete"})
115
+
116
+
117
+ async def _handle_http(
118
+ context: Context,
119
+ base_domain: str | None,
120
+ scope: Scope,
121
+ receive: Receive,
122
+ send: Send,
123
+ ) -> None:
124
+ request_id = secrets.token_hex(8)
125
+ started = time.monotonic()
126
+ http_request = _parse_http(scope)
127
+ access = {
128
+ "request_id": request_id,
129
+ "method": http_request.method,
130
+ "path": http_request.raw_path.decode("latin-1"),
131
+ "operation": "unknown",
132
+ }
133
+ route: routing.Route | None = None
134
+ try:
135
+ route = routing.route(http_request, base_domain)
136
+ access["operation"] = route.operation.name
137
+ response = await _dispatch(context, http_request, route, receive)
138
+ except S3Error as error:
139
+ response = _error_response(error, http_request.raw_path, request_id)
140
+ except ClientDisconnectedError:
141
+ _log_access(access, _STATUS_CLIENT_GONE, started)
142
+ return
143
+ except Exception:
144
+ logger.exception("Unhandled error in request %s", request_id)
145
+ response = _error_response(
146
+ S3Error(errors.INTERNAL_ERROR), http_request.raw_path, request_id
147
+ )
148
+
149
+ response = await _allow_browser_to_read(context, http_request, route, response)
150
+ if "expect" in http_request.headers and response.status >= _FIRST_ERROR_STATUS:
151
+ # The client held its body back and will send it after no 100 Continue. If
152
+ # the connection stayed open, those bytes would parse as the next request.
153
+ response = replace(response, headers=(*response.headers, _CONNECTION_CLOSE))
154
+ try:
155
+ await _send_response(response, http_request.method, request_id, receive, send)
156
+ finally:
157
+ # Including a stream that fails midway: its status was already sent.
158
+ _log_access(access, response.status, started)
159
+
160
+
161
+ async def _allow_browser_to_read(
162
+ context: Context,
163
+ request: HttpRequest,
164
+ route: routing.Route | None,
165
+ response: Response,
166
+ ) -> Response:
167
+ """Add CORS headers, to errors too, or a page couldn't see why a request failed.
168
+
169
+ Only a request with an Origin pays for the lookup, which leaves other clients alone.
170
+ """
171
+ origin = request.headers.get("origin")
172
+ if (
173
+ origin is None
174
+ or not isinstance(route, BucketRoute | ObjectRoute)
175
+ or route.operation is Operation.CORS_PREFLIGHT
176
+ ):
177
+ return response
178
+ allowed = await handlers.cors_headers_for(
179
+ context, request.method, origin, route.bucket
180
+ )
181
+ return replace(response, headers=(*response.headers, *allowed))
182
+
183
+
184
+ def _parse_http(scope: Scope) -> HttpRequest:
185
+ headers: dict[str, str] = {}
186
+ for raw_name, raw_value in scope["headers"]:
187
+ name = raw_name.decode("latin-1").lower()
188
+ value = raw_value.decode("latin-1")
189
+ headers[name] = f"{headers[name]},{value}" if name in headers else value
190
+ raw_query: bytes = scope["query_string"]
191
+ return HttpRequest(
192
+ scope["method"],
193
+ scope["raw_path"],
194
+ raw_query,
195
+ routing.parse_query(raw_query),
196
+ headers,
197
+ scope.get("scheme") == "https",
198
+ )
199
+
200
+
201
+ async def _dispatch(
202
+ context: Context,
203
+ http_request: HttpRequest,
204
+ route: routing.Route,
205
+ receive: Receive,
206
+ ) -> Response:
207
+ unsigned = await _answer_unsigned(context, http_request, route, receive)
208
+ if unsigned is not None:
209
+ return unsigned
210
+
211
+ if sigv4.is_anonymous(http_request):
212
+ scope, seed = access.ANONYMOUS, None
213
+ await _authorize_anonymous(context, route)
214
+ else:
215
+ claim = sigv4.parse_claim(http_request)
216
+ secret, scope = await meta.get_access_key(
217
+ context.db, claim.credential.access_key
218
+ )
219
+ seed = sigv4.verify(http_request, claim, secret, time.time())
220
+ access.authorize(scope, route)
221
+ request = Request(
222
+ http_request.headers,
223
+ http_request.query,
224
+ receive,
225
+ seed,
226
+ scope,
227
+ http_request.secure,
228
+ )
229
+
230
+ match route:
231
+ case ServiceRoute(operation=operation):
232
+ return await handlers.SERVICE_HANDLERS[operation](context, request)
233
+ case BucketRoute(operation=operation, bucket=bucket):
234
+ return await handlers.BUCKET_HANDLERS[operation](context, request, bucket)
235
+ case ObjectRoute(operation=operation, bucket=bucket, key=key):
236
+ return await handlers.OBJECT_HANDLERS[operation](
237
+ context, request, bucket, key
238
+ )
239
+
240
+
241
+ async def _answer_unsigned(
242
+ context: Context,
243
+ http_request: HttpRequest,
244
+ route: routing.Route,
245
+ receive: Receive,
246
+ ) -> Response | None:
247
+ """Answer a request that has no signature in the usual place, if this is one."""
248
+ if route.operation is Operation.HEALTH:
249
+ await meta.ping(context.db)
250
+ return Response(body=b"ok\n", headers=(("content-type", "text/plain"),))
251
+ if route.operation is Operation.METRICS:
252
+ return await _metrics_response(context)
253
+ if isinstance(route, ServiceRoute):
254
+ return None
255
+ if route.operation is Operation.CORS_PREFLIGHT:
256
+ return await handlers.cors_preflight(
257
+ context, http_request.headers, route.bucket
258
+ )
259
+ if route.operation is Operation.POST_OBJECT:
260
+ # The form itself holds the signature, which comes with the body.
261
+ form = Request(
262
+ http_request.headers,
263
+ http_request.query,
264
+ receive,
265
+ None,
266
+ access.ANONYMOUS,
267
+ )
268
+ return await handlers.post_object(context, form, route.bucket)
269
+ return None
270
+
271
+
272
+ async def _authorize_anonymous(context: Context, route: routing.Route) -> None:
273
+ """Let through only what an ACL could allow. Reads check the object's own ACL."""
274
+ refusal = S3Error(errors.ACCESS_DENIED, "Anonymous access is not allowed here.")
275
+ if isinstance(route, ObjectRoute):
276
+ if route.operation not in access.ANONYMOUS_OBJECT_OPERATIONS:
277
+ raise refusal
278
+ elif not (
279
+ isinstance(route, BucketRoute)
280
+ and route.operation in access.ANONYMOUS_BUCKET_OPERATIONS
281
+ and await meta.bucket_is_public(context.db, route.bucket)
282
+ ):
283
+ raise refusal
284
+
285
+
286
+ async def _metrics_response(context: Context) -> Response:
287
+ """Answer without a signature, which Prometheus can't make: it names no bucket."""
288
+ gauges = await maintenance.current_gauges(context.db, context.data_dir)
289
+ samples = await asyncio.to_thread(metrics.all_workers, context.data_dir)
290
+ return Response(
291
+ body=metrics.render([*samples, *gauges]).encode(),
292
+ headers=(("content-type", _METRICS_CONTENT_TYPE),),
293
+ )
294
+
295
+
296
+ def _error_response(error: S3Error, raw_path: bytes, request_id: str) -> Response:
297
+ if error.error.status >= _FIRST_SERVER_ERROR_STATUS:
298
+ logger.error("%s: %s", request_id, error)
299
+ body = xml.render_error(
300
+ error.error.code,
301
+ error.message,
302
+ raw_path.decode("latin-1"),
303
+ request_id,
304
+ )
305
+ return Response(
306
+ error.error.status,
307
+ (("content-type", "application/xml"), *error.headers),
308
+ body,
309
+ )
310
+
311
+
312
+ async def _send_response(
313
+ response: Response, method: str, request_id: str, receive: Receive, send: Send
314
+ ) -> None:
315
+ headers = [
316
+ (name.encode("latin-1"), value.encode("latin-1"))
317
+ for name, value in response.headers
318
+ ]
319
+ headers.append((b"x-amz-request-id", request_id.encode()))
320
+ body = response.body
321
+ has_length = any(name == b"content-length" for name, _ in headers)
322
+ if (
323
+ isinstance(body, bytes)
324
+ and not has_length
325
+ and response.status not in _STATUSES_WITHOUT_BODY
326
+ ):
327
+ headers.append((b"content-length", str(len(body)).encode()))
328
+ await send({
329
+ "type": "http.response.start",
330
+ "status": response.status,
331
+ "headers": headers,
332
+ })
333
+ if isinstance(body, bytes):
334
+ payload = b"" if method == "HEAD" else body
335
+ await send({"type": "http.response.body", "body": payload})
336
+ else:
337
+ await _stream_body(body, request_id, receive, send)
338
+
339
+
340
+ async def _stream_body(
341
+ body: AsyncGenerator[bytes], request_id: str, receive: Receive, send: Send
342
+ ) -> None:
343
+ """Send a blob stream, and stop reading the disk once the client has gone.
344
+
345
+ uvicorn silently drops `send` after a disconnect, so nothing else would stop
346
+ an abandoned download from reading the whole object.
347
+ """
348
+ disconnected = asyncio.create_task(_wait_for_disconnect(receive))
349
+ sent = 0
350
+ try:
351
+ async with aclosing(body) as chunks:
352
+ async for chunk in chunks:
353
+ if disconnected.done():
354
+ # The status logged is still the one the client was sent.
355
+ logger.info("%s: connection lost after %d bytes", request_id, sent)
356
+ return
357
+ sent += len(chunk)
358
+ await send({
359
+ "type": "http.response.body",
360
+ "body": chunk,
361
+ "more_body": True,
362
+ })
363
+ await send({"type": "http.response.body", "body": b""})
364
+ finally:
365
+ disconnected.cancel()
366
+
367
+
368
+ async def _wait_for_disconnect(receive: Receive) -> None:
369
+ while (await receive())["type"] != "http.disconnect":
370
+ pass
371
+
372
+
373
+ def _log_access(access: Mapping[str, str], status: int, started: float) -> None:
374
+ seconds = round(time.monotonic() - started, 6)
375
+ metrics.counters.observe_request(access["operation"], status, seconds)
376
+ logger.info("%s", json.dumps({**access, "status": status, "seconds": seconds}))