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 +1 -0
- leblob/asgi.py +376 -0
- leblob/blobstore.py +594 -0
- leblob/cli.py +499 -0
- leblob/handlers.py +1119 -0
- leblob/maintenance.py +444 -0
- leblob/meta.py +2327 -0
- leblob/metrics.py +226 -0
- leblob/model.py +339 -0
- leblob/s3/__init__.py +1 -0
- leblob/s3/access.py +110 -0
- leblob/s3/chunked.py +175 -0
- leblob/s3/conditions.py +156 -0
- leblob/s3/cors.py +80 -0
- leblob/s3/errors.py +144 -0
- leblob/s3/lifecycle.py +119 -0
- leblob/s3/listing.py +196 -0
- leblob/s3/multipart.py +184 -0
- leblob/s3/payload.py +328 -0
- leblob/s3/post_form.py +344 -0
- leblob/s3/routing.py +443 -0
- leblob/s3/sigv4.py +333 -0
- leblob/s3/sse.py +229 -0
- leblob/s3/tagging.py +48 -0
- leblob/s3/xml.py +436 -0
- leblob/s3/xml_base.py +64 -0
- leblob/s3/xml_config.py +431 -0
- leblob/streams.py +179 -0
- leblob-0.4.0.dev1.dist-info/METADATA +109 -0
- leblob-0.4.0.dev1.dist-info/RECORD +33 -0
- leblob-0.4.0.dev1.dist-info/WHEEL +4 -0
- leblob-0.4.0.dev1.dist-info/entry_points.txt +3 -0
- leblob-0.4.0.dev1.dist-info/licenses/LICENSE +202 -0
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}))
|