theblob 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.
- theblob/__init__.py +1 -0
- theblob/asgi.py +238 -0
- theblob/blobstore.py +190 -0
- theblob/cli.py +211 -0
- theblob/handlers.py +523 -0
- theblob/maintenance.py +100 -0
- theblob/meta.py +723 -0
- theblob/model.py +105 -0
- theblob/s3/__init__.py +1 -0
- theblob/s3/chunked.py +175 -0
- theblob/s3/conditions.py +135 -0
- theblob/s3/errors.py +117 -0
- theblob/s3/listing.py +140 -0
- theblob/s3/multipart.py +77 -0
- theblob/s3/payload.py +257 -0
- theblob/s3/routing.py +279 -0
- theblob/s3/sigv4.py +292 -0
- theblob/s3/xml.py +400 -0
- theblob-0.1.0.dist-info/METADATA +53 -0
- theblob-0.1.0.dist-info/RECORD +22 -0
- theblob-0.1.0.dist-info/WHEEL +4 -0
- theblob-0.1.0.dist-info/entry_points.txt +3 -0
theblob/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""theblob: an S3-compatible object store."""
|
theblob/asgi.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
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 theblob import handlers, maintenance, meta
|
|
17
|
+
from theblob.blobstore import BlobStore
|
|
18
|
+
from theblob.handlers import ClientDisconnectedError, Context, Request, Response
|
|
19
|
+
from theblob.meta import Database
|
|
20
|
+
from theblob.model import HttpRequest
|
|
21
|
+
from theblob.s3 import errors, routing, sigv4, xml
|
|
22
|
+
from theblob.s3.errors import S3Error
|
|
23
|
+
from theblob.s3.routing import BucketRoute, ObjectRoute, Operation, ServiceRoute
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from collections.abc import AsyncGenerator, Awaitable, Callable, Mapping
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
type Scope = Mapping[str, Any]
|
|
30
|
+
type Message = Mapping[str, Any]
|
|
31
|
+
type Receive = Callable[[], Awaitable[Message]]
|
|
32
|
+
type Send = Callable[[Message], Awaitable[None]]
|
|
33
|
+
type App = Callable[[Scope, Receive, Send], Awaitable[None]]
|
|
34
|
+
|
|
35
|
+
ENV_DATA_DIR = "THEBLOB_DATA_DIR"
|
|
36
|
+
ENV_BASE_DOMAIN = "THEBLOB_BASE_DOMAIN"
|
|
37
|
+
META_FILE = "meta.db"
|
|
38
|
+
|
|
39
|
+
_CONNECTION_CLOSE = ("connection", "close")
|
|
40
|
+
_FIRST_ERROR_STATUS = 400
|
|
41
|
+
_FIRST_SERVER_ERROR_STATUS = 500
|
|
42
|
+
_STATUS_CLIENT_GONE = 499
|
|
43
|
+
_STATUSES_WITHOUT_BODY = frozenset({204, 304})
|
|
44
|
+
|
|
45
|
+
logger = logging.getLogger("theblob.access")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def create_app_from_env() -> App:
|
|
49
|
+
"""Build the application in a worker process, from what `serve` set up."""
|
|
50
|
+
# Here and not in `serve`: each worker is its own process, with its own logging.
|
|
51
|
+
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
|
52
|
+
data_dir = Path(os.environ[ENV_DATA_DIR])
|
|
53
|
+
context = Context(Database(data_dir / META_FILE), BlobStore(data_dir))
|
|
54
|
+
return create_app(context, os.environ.get(ENV_BASE_DOMAIN))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def create_app(context: Context, base_domain: str | None) -> App:
|
|
58
|
+
async def app(scope: Scope, receive: Receive, send: Send) -> None:
|
|
59
|
+
if scope["type"] == "lifespan":
|
|
60
|
+
await _run_lifespan(context, receive, send)
|
|
61
|
+
else:
|
|
62
|
+
await _handle_http(context, base_domain, scope, receive, send)
|
|
63
|
+
|
|
64
|
+
return app
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
async def _run_lifespan(context: Context, receive: Receive, send: Send) -> None:
|
|
68
|
+
await receive()
|
|
69
|
+
task = asyncio.create_task(maintenance.run_forever(context.db, context.blobs))
|
|
70
|
+
await send({"type": "lifespan.startup.complete"})
|
|
71
|
+
await receive()
|
|
72
|
+
task.cancel()
|
|
73
|
+
with suppress(asyncio.CancelledError):
|
|
74
|
+
await task
|
|
75
|
+
context.db.close()
|
|
76
|
+
await send({"type": "lifespan.shutdown.complete"})
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
async def _handle_http(
|
|
80
|
+
context: Context,
|
|
81
|
+
base_domain: str | None,
|
|
82
|
+
scope: Scope,
|
|
83
|
+
receive: Receive,
|
|
84
|
+
send: Send,
|
|
85
|
+
) -> None:
|
|
86
|
+
request_id = secrets.token_hex(8)
|
|
87
|
+
started = time.monotonic()
|
|
88
|
+
http_request = _parse_http(scope)
|
|
89
|
+
access = {
|
|
90
|
+
"request_id": request_id,
|
|
91
|
+
"method": http_request.method,
|
|
92
|
+
"path": http_request.raw_path.decode("latin-1"),
|
|
93
|
+
"operation": "unknown",
|
|
94
|
+
}
|
|
95
|
+
try:
|
|
96
|
+
route = routing.route(http_request, base_domain)
|
|
97
|
+
access["operation"] = route.operation.name
|
|
98
|
+
response = await _dispatch(context, http_request, route, receive)
|
|
99
|
+
except S3Error as error:
|
|
100
|
+
response = _error_response(error, http_request.raw_path, request_id)
|
|
101
|
+
except ClientDisconnectedError:
|
|
102
|
+
_log_access(access, _STATUS_CLIENT_GONE, started)
|
|
103
|
+
return
|
|
104
|
+
except Exception:
|
|
105
|
+
logger.exception("Unhandled error in request %s", request_id)
|
|
106
|
+
response = _error_response(
|
|
107
|
+
S3Error(errors.INTERNAL_ERROR), http_request.raw_path, request_id
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
if "expect" in http_request.headers and response.status >= _FIRST_ERROR_STATUS:
|
|
111
|
+
# The client held its body back and will send it after no 100 Continue. If
|
|
112
|
+
# the connection stayed open, those bytes would parse as the next request.
|
|
113
|
+
response = replace(response, headers=(*response.headers, _CONNECTION_CLOSE))
|
|
114
|
+
try:
|
|
115
|
+
await _send_response(response, http_request.method, request_id, receive, send)
|
|
116
|
+
finally:
|
|
117
|
+
# Including a stream that fails midway: its status was already sent.
|
|
118
|
+
_log_access(access, response.status, started)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _parse_http(scope: Scope) -> HttpRequest:
|
|
122
|
+
headers: dict[str, str] = {}
|
|
123
|
+
for raw_name, raw_value in scope["headers"]:
|
|
124
|
+
name = raw_name.decode("latin-1").lower()
|
|
125
|
+
value = raw_value.decode("latin-1")
|
|
126
|
+
headers[name] = f"{headers[name]},{value}" if name in headers else value
|
|
127
|
+
raw_query: bytes = scope["query_string"]
|
|
128
|
+
return HttpRequest(
|
|
129
|
+
scope["method"],
|
|
130
|
+
scope["raw_path"],
|
|
131
|
+
raw_query,
|
|
132
|
+
routing.parse_query(raw_query),
|
|
133
|
+
headers,
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
async def _dispatch(
|
|
138
|
+
context: Context,
|
|
139
|
+
http_request: HttpRequest,
|
|
140
|
+
route: routing.Route,
|
|
141
|
+
receive: Receive,
|
|
142
|
+
) -> Response:
|
|
143
|
+
if route.operation is Operation.HEALTH:
|
|
144
|
+
await meta.ping(context.db)
|
|
145
|
+
return Response(body=b"ok\n", headers=(("content-type", "text/plain"),))
|
|
146
|
+
|
|
147
|
+
claim = sigv4.parse_claim(http_request)
|
|
148
|
+
secret = await meta.get_secret(context.db, claim.credential.access_key)
|
|
149
|
+
seed = sigv4.verify(http_request, claim, secret, time.time())
|
|
150
|
+
request = Request(http_request.headers, http_request.query, receive, seed)
|
|
151
|
+
|
|
152
|
+
match route:
|
|
153
|
+
case ServiceRoute(operation=operation):
|
|
154
|
+
return await handlers.SERVICE_HANDLERS[operation](context, request)
|
|
155
|
+
case BucketRoute(operation=operation, bucket=bucket):
|
|
156
|
+
return await handlers.BUCKET_HANDLERS[operation](context, request, bucket)
|
|
157
|
+
case ObjectRoute(operation=operation, bucket=bucket, key=key):
|
|
158
|
+
return await handlers.OBJECT_HANDLERS[operation](
|
|
159
|
+
context, request, bucket, key
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _error_response(error: S3Error, raw_path: bytes, request_id: str) -> Response:
|
|
164
|
+
if error.error.status >= _FIRST_SERVER_ERROR_STATUS:
|
|
165
|
+
logger.error("%s: %s", request_id, error)
|
|
166
|
+
body = xml.render_error(
|
|
167
|
+
error.error.code,
|
|
168
|
+
error.message,
|
|
169
|
+
raw_path.decode("latin-1"),
|
|
170
|
+
request_id,
|
|
171
|
+
)
|
|
172
|
+
return Response(
|
|
173
|
+
error.error.status,
|
|
174
|
+
(("content-type", "application/xml"), *error.headers),
|
|
175
|
+
body,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
async def _send_response(
|
|
180
|
+
response: Response, method: str, request_id: str, receive: Receive, send: Send
|
|
181
|
+
) -> None:
|
|
182
|
+
headers = [
|
|
183
|
+
(name.encode("latin-1"), value.encode("latin-1"))
|
|
184
|
+
for name, value in response.headers
|
|
185
|
+
]
|
|
186
|
+
headers.append((b"x-amz-request-id", request_id.encode()))
|
|
187
|
+
body = response.body
|
|
188
|
+
has_length = any(name == b"content-length" for name, _ in headers)
|
|
189
|
+
if (
|
|
190
|
+
isinstance(body, bytes)
|
|
191
|
+
and not has_length
|
|
192
|
+
and response.status not in _STATUSES_WITHOUT_BODY
|
|
193
|
+
):
|
|
194
|
+
headers.append((b"content-length", str(len(body)).encode()))
|
|
195
|
+
await send({
|
|
196
|
+
"type": "http.response.start",
|
|
197
|
+
"status": response.status,
|
|
198
|
+
"headers": headers,
|
|
199
|
+
})
|
|
200
|
+
if isinstance(body, bytes):
|
|
201
|
+
payload = b"" if method == "HEAD" else body
|
|
202
|
+
await send({"type": "http.response.body", "body": payload})
|
|
203
|
+
else:
|
|
204
|
+
await _stream_body(body, receive, send)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
async def _stream_body(
|
|
208
|
+
body: AsyncGenerator[bytes], receive: Receive, send: Send
|
|
209
|
+
) -> None:
|
|
210
|
+
"""Send a blob stream, and stop reading the disk once the client has gone.
|
|
211
|
+
|
|
212
|
+
uvicorn silently drops `send` after a disconnect, so nothing else would stop
|
|
213
|
+
an abandoned download from reading the whole object.
|
|
214
|
+
"""
|
|
215
|
+
disconnected = asyncio.create_task(_wait_for_disconnect(receive))
|
|
216
|
+
try:
|
|
217
|
+
async with aclosing(body) as chunks:
|
|
218
|
+
async for chunk in chunks:
|
|
219
|
+
if disconnected.done():
|
|
220
|
+
return
|
|
221
|
+
await send({
|
|
222
|
+
"type": "http.response.body",
|
|
223
|
+
"body": chunk,
|
|
224
|
+
"more_body": True,
|
|
225
|
+
})
|
|
226
|
+
await send({"type": "http.response.body", "body": b""})
|
|
227
|
+
finally:
|
|
228
|
+
disconnected.cancel()
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
async def _wait_for_disconnect(receive: Receive) -> None:
|
|
232
|
+
while (await receive())["type"] != "http.disconnect":
|
|
233
|
+
pass
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _log_access(access: Mapping[str, str], status: int, started: float) -> None:
|
|
237
|
+
seconds = round(time.monotonic() - started, 6)
|
|
238
|
+
logger.info("%s", json.dumps({**access, "status": status, "seconds": seconds}))
|
theblob/blobstore.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Blob files: durable writes, range reads and deletion.
|
|
2
|
+
|
|
3
|
+
A blob's ID is random, so user-supplied keys never reach the filesystem.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import os
|
|
10
|
+
import secrets
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import TYPE_CHECKING, Self
|
|
14
|
+
|
|
15
|
+
from theblob.model import Blob
|
|
16
|
+
from theblob.s3 import errors
|
|
17
|
+
from theblob.s3.errors import S3Error
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING:
|
|
20
|
+
from collections.abc import AsyncGenerator, AsyncIterable, Iterable, Mapping
|
|
21
|
+
from types import TracebackType
|
|
22
|
+
|
|
23
|
+
from theblob.model import BlobSlice
|
|
24
|
+
from theblob.s3.payload import Hasher
|
|
25
|
+
|
|
26
|
+
BUFFER_BYTES = 1024 * 1024
|
|
27
|
+
TMP_MAX_AGE_SECONDS = 24 * 3600
|
|
28
|
+
|
|
29
|
+
_SHARD_LENGTH = 3
|
|
30
|
+
_SHARD_COUNT = 16**_SHARD_LENGTH
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def existing_files(directory: Path) -> Iterable[tuple[str, os.stat_result]]:
|
|
34
|
+
"""Yield the files in a directory that other workers are changing concurrently.
|
|
35
|
+
|
|
36
|
+
A file listed a moment ago may have been committed or collected since.
|
|
37
|
+
"""
|
|
38
|
+
with os.scandir(directory) as entries:
|
|
39
|
+
for entry in entries:
|
|
40
|
+
try:
|
|
41
|
+
yield entry.path, entry.stat()
|
|
42
|
+
except FileNotFoundError:
|
|
43
|
+
continue
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _fsync_directory(path: Path) -> None:
|
|
47
|
+
descriptor = os.open(path, os.O_RDONLY)
|
|
48
|
+
try:
|
|
49
|
+
os.fsync(descriptor)
|
|
50
|
+
finally:
|
|
51
|
+
os.close(descriptor)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True, slots=True)
|
|
55
|
+
class BlobStore:
|
|
56
|
+
"""A directory of immutable blob files."""
|
|
57
|
+
|
|
58
|
+
root: Path
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def tmp_dir(self) -> Path:
|
|
62
|
+
return self.root / "tmp"
|
|
63
|
+
|
|
64
|
+
@property
|
|
65
|
+
def blobs_dir(self) -> Path:
|
|
66
|
+
return self.root / "blobs"
|
|
67
|
+
|
|
68
|
+
def initialize(self) -> None:
|
|
69
|
+
"""Create every shard up front, so writes never create a directory."""
|
|
70
|
+
self.tmp_dir.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
for number in range(_SHARD_COUNT):
|
|
72
|
+
shard = f"{number:0{_SHARD_LENGTH}x}"
|
|
73
|
+
(self.blobs_dir / shard).mkdir(parents=True, exist_ok=True)
|
|
74
|
+
|
|
75
|
+
def path(self, blob_id: str) -> Path:
|
|
76
|
+
return self.blobs_dir / blob_id[:_SHARD_LENGTH] / blob_id
|
|
77
|
+
|
|
78
|
+
def writer(self, hashers: Mapping[str, Hasher]) -> BlobWriter:
|
|
79
|
+
return BlobWriter(self, hashers)
|
|
80
|
+
|
|
81
|
+
def delete(self, blob_id: str) -> None:
|
|
82
|
+
self.path(blob_id).unlink(missing_ok=True)
|
|
83
|
+
|
|
84
|
+
def sweep_tmp(self, now: float) -> int:
|
|
85
|
+
"""Remove temp files abandoned by a crash. Returns how many were removed."""
|
|
86
|
+
removed = 0
|
|
87
|
+
for path, stat in existing_files(self.tmp_dir):
|
|
88
|
+
if now - stat.st_mtime > TMP_MAX_AGE_SECONDS:
|
|
89
|
+
Path(path).unlink(missing_ok=True)
|
|
90
|
+
removed += 1
|
|
91
|
+
return removed
|
|
92
|
+
|
|
93
|
+
def scan(self) -> Iterable[tuple[str, int, float]]:
|
|
94
|
+
"""Yield (blob ID, size, modification time) for every blob on disk."""
|
|
95
|
+
for shard in self.blobs_dir.iterdir():
|
|
96
|
+
for path, stat in existing_files(shard):
|
|
97
|
+
yield Path(path).name, stat.st_size, stat.st_mtime
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class BlobWriter:
|
|
101
|
+
"""One blob being written. It only becomes durable and named on `commit`."""
|
|
102
|
+
|
|
103
|
+
def __init__(self, store: BlobStore, hashers: Mapping[str, Hasher]) -> None:
|
|
104
|
+
self._store = store
|
|
105
|
+
self._hashers = hashers
|
|
106
|
+
self.blob_id = secrets.token_hex(16)
|
|
107
|
+
self._tmp_path = store.tmp_dir / self.blob_id
|
|
108
|
+
self._file = self._tmp_path.open("xb")
|
|
109
|
+
self._committed = False
|
|
110
|
+
self.size = 0
|
|
111
|
+
|
|
112
|
+
def __enter__(self) -> Self:
|
|
113
|
+
return self
|
|
114
|
+
|
|
115
|
+
def __exit__(
|
|
116
|
+
self,
|
|
117
|
+
exc_type: type[BaseException] | None,
|
|
118
|
+
exc: BaseException | None,
|
|
119
|
+
traceback: TracebackType | None,
|
|
120
|
+
) -> None:
|
|
121
|
+
self._file.close()
|
|
122
|
+
if not self._committed:
|
|
123
|
+
self._tmp_path.unlink(missing_ok=True)
|
|
124
|
+
|
|
125
|
+
def write(self, data: bytes) -> None:
|
|
126
|
+
"""Hash and write one buffer. Runs on a worker thread."""
|
|
127
|
+
for hasher in self._hashers.values():
|
|
128
|
+
hasher.update(data)
|
|
129
|
+
self._file.write(data)
|
|
130
|
+
self.size += len(data)
|
|
131
|
+
|
|
132
|
+
def digests(self) -> dict[str, bytes]:
|
|
133
|
+
return {name: hasher.digest() for name, hasher in self._hashers.items()}
|
|
134
|
+
|
|
135
|
+
def commit(self) -> Blob:
|
|
136
|
+
"""Make the blob durable under its final name. Runs on a worker thread."""
|
|
137
|
+
self._file.flush()
|
|
138
|
+
# ponytail: on macOS fsync doesn't flush the drive cache. Linux is the
|
|
139
|
+
# target; add fcntl F_FULLFSYNC if it is ever deployed on macOS.
|
|
140
|
+
os.fsync(self._file.fileno())
|
|
141
|
+
self._file.close()
|
|
142
|
+
|
|
143
|
+
final_path = self._store.path(self.blob_id)
|
|
144
|
+
self._tmp_path.rename(final_path)
|
|
145
|
+
_fsync_directory(final_path.parent)
|
|
146
|
+
self._committed = True
|
|
147
|
+
return Blob(self.blob_id, self.size, self._hashers["md5"].digest().hex())
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
async def write_stream(
|
|
151
|
+
writer: BlobWriter, chunks: AsyncIterable[bytes], max_bytes: int
|
|
152
|
+
) -> None:
|
|
153
|
+
"""Feed a stream to a writer, one thread-pool trip per buffer of data."""
|
|
154
|
+
buffer = bytearray()
|
|
155
|
+
received = 0
|
|
156
|
+
async for chunk in chunks:
|
|
157
|
+
received += len(chunk)
|
|
158
|
+
if received > max_bytes:
|
|
159
|
+
raise S3Error(errors.ENTITY_TOO_LARGE)
|
|
160
|
+
buffer += chunk
|
|
161
|
+
if len(buffer) >= BUFFER_BYTES:
|
|
162
|
+
await asyncio.to_thread(writer.write, bytes(buffer))
|
|
163
|
+
buffer.clear()
|
|
164
|
+
if buffer:
|
|
165
|
+
await asyncio.to_thread(writer.write, bytes(buffer))
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
async def read_slices(
|
|
169
|
+
store: BlobStore, slices: Iterable[BlobSlice]
|
|
170
|
+
) -> AsyncGenerator[bytes]:
|
|
171
|
+
"""Stream byte ranges of blobs, opening each blob only when it is reached."""
|
|
172
|
+
for blob_slice in slices:
|
|
173
|
+
descriptor = await asyncio.to_thread(
|
|
174
|
+
os.open, store.path(blob_slice.blob_id), os.O_RDONLY
|
|
175
|
+
)
|
|
176
|
+
try:
|
|
177
|
+
position = blob_slice.offset
|
|
178
|
+
remaining = blob_slice.length
|
|
179
|
+
while remaining > 0:
|
|
180
|
+
data = await asyncio.to_thread(
|
|
181
|
+
os.pread, descriptor, min(BUFFER_BYTES, remaining), position
|
|
182
|
+
)
|
|
183
|
+
if not data:
|
|
184
|
+
message = f"Blob {blob_slice.blob_id} is shorter than its metadata."
|
|
185
|
+
raise OSError(message)
|
|
186
|
+
position += len(data)
|
|
187
|
+
remaining -= len(data)
|
|
188
|
+
yield data
|
|
189
|
+
finally:
|
|
190
|
+
os.close(descriptor)
|
theblob/cli.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""The `theblob` command."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import os
|
|
7
|
+
import secrets
|
|
8
|
+
import string
|
|
9
|
+
import sys
|
|
10
|
+
import time
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import TYPE_CHECKING, Annotated
|
|
14
|
+
|
|
15
|
+
import cyclopts
|
|
16
|
+
import uvicorn
|
|
17
|
+
|
|
18
|
+
from theblob import asgi, maintenance, meta
|
|
19
|
+
from theblob.blobstore import BlobStore
|
|
20
|
+
from theblob.meta import Database
|
|
21
|
+
|
|
22
|
+
if TYPE_CHECKING:
|
|
23
|
+
from collections.abc import Awaitable, Callable
|
|
24
|
+
from typing import Concatenate
|
|
25
|
+
|
|
26
|
+
_DEFAULT_DATA_DIR = Path("./data")
|
|
27
|
+
_ACCESS_KEY_ALPHABET = string.ascii_uppercase + string.digits
|
|
28
|
+
_ACCESS_KEY_LENGTH = 20
|
|
29
|
+
_SECRET_BYTES = 30
|
|
30
|
+
|
|
31
|
+
DataDir = Annotated[Path, cyclopts.Parameter(help="Directory holding all stored data.")]
|
|
32
|
+
|
|
33
|
+
app = cyclopts.App(name="theblob", help="An S3-compatible object store.")
|
|
34
|
+
key_app = cyclopts.App(name="key", help="Manage access keys.")
|
|
35
|
+
app.command(key_app)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@cyclopts.Parameter(name="*")
|
|
39
|
+
@dataclass(frozen=True, kw_only=True)
|
|
40
|
+
class ServeOptions:
|
|
41
|
+
"""Options of `theblob serve`.
|
|
42
|
+
|
|
43
|
+
Parameters
|
|
44
|
+
----------
|
|
45
|
+
data_dir
|
|
46
|
+
Directory holding all stored data.
|
|
47
|
+
host
|
|
48
|
+
Address to listen on.
|
|
49
|
+
port
|
|
50
|
+
Port to listen on.
|
|
51
|
+
workers
|
|
52
|
+
Number of worker processes; defaults to the number of CPUs.
|
|
53
|
+
domain
|
|
54
|
+
Base domain enabling virtual-hosted addressing (bucket.domain).
|
|
55
|
+
ssl_certfile
|
|
56
|
+
TLS certificate, to serve HTTPS directly.
|
|
57
|
+
ssl_keyfile
|
|
58
|
+
TLS private key.
|
|
59
|
+
shutdown_grace
|
|
60
|
+
Seconds to let requests finish on shutdown. Without a limit, one client
|
|
61
|
+
that stops reading would prevent the server from ever exiting.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
data_dir: Path = _DEFAULT_DATA_DIR
|
|
65
|
+
host: str = "127.0.0.1"
|
|
66
|
+
port: int = 9000
|
|
67
|
+
workers: int | None = None
|
|
68
|
+
domain: str | None = None
|
|
69
|
+
ssl_certfile: Path | None = None
|
|
70
|
+
ssl_keyfile: Path | None = None
|
|
71
|
+
shutdown_grace: int = 20
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@app.command
|
|
75
|
+
def serve(options: ServeOptions | None = None) -> None:
|
|
76
|
+
"""Run the S3 server."""
|
|
77
|
+
options = options or ServeOptions()
|
|
78
|
+
_prepare(options.data_dir)
|
|
79
|
+
asyncio.run(_create_first_key_if_needed(options.data_dir))
|
|
80
|
+
|
|
81
|
+
os.environ[asgi.ENV_DATA_DIR] = str(options.data_dir.resolve())
|
|
82
|
+
if options.domain:
|
|
83
|
+
os.environ[asgi.ENV_BASE_DOMAIN] = options.domain
|
|
84
|
+
else:
|
|
85
|
+
os.environ.pop(asgi.ENV_BASE_DOMAIN, None)
|
|
86
|
+
|
|
87
|
+
uvicorn.run(
|
|
88
|
+
"theblob.asgi:create_app_from_env",
|
|
89
|
+
factory=True,
|
|
90
|
+
host=options.host,
|
|
91
|
+
port=options.port,
|
|
92
|
+
workers=options.workers or os.cpu_count(),
|
|
93
|
+
http="httptools",
|
|
94
|
+
loop="uvloop",
|
|
95
|
+
lifespan="on",
|
|
96
|
+
ws="none",
|
|
97
|
+
access_log=False,
|
|
98
|
+
ssl_certfile=options.ssl_certfile,
|
|
99
|
+
ssl_keyfile=options.ssl_keyfile,
|
|
100
|
+
timeout_graceful_shutdown=options.shutdown_grace,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@key_app.command(name="create")
|
|
105
|
+
def key_create(*, data_dir: DataDir = _DEFAULT_DATA_DIR) -> None:
|
|
106
|
+
"""Create an access key and print it. The secret isn't shown again."""
|
|
107
|
+
_prepare(data_dir)
|
|
108
|
+
access_key, secret = asyncio.run(_create_key(data_dir))
|
|
109
|
+
print(f"access key: {access_key}")
|
|
110
|
+
print(f"secret key: {secret}")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@key_app.command(name="list")
|
|
114
|
+
def key_list(*, data_dir: DataDir = _DEFAULT_DATA_DIR) -> None:
|
|
115
|
+
"""List access keys."""
|
|
116
|
+
_open_existing(data_dir)
|
|
117
|
+
for access_key in asyncio.run(_with_db(data_dir, meta.list_access_keys)):
|
|
118
|
+
print(access_key)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@key_app.command(name="delete")
|
|
122
|
+
def key_delete(access_key: str, /, *, data_dir: DataDir = _DEFAULT_DATA_DIR) -> None:
|
|
123
|
+
"""Delete an access key."""
|
|
124
|
+
_open_existing(data_dir)
|
|
125
|
+
if not asyncio.run(_with_db(data_dir, meta.delete_access_key, access_key)):
|
|
126
|
+
sys.exit(f"theblob: no such access key: {access_key}")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@app.command
|
|
130
|
+
def fsck(
|
|
131
|
+
*, data_dir: DataDir = _DEFAULT_DATA_DIR, delete_orphans: bool = False
|
|
132
|
+
) -> None:
|
|
133
|
+
"""Compare the metadata with the disk; exit non-zero if they disagree.
|
|
134
|
+
|
|
135
|
+
Parameters
|
|
136
|
+
----------
|
|
137
|
+
delete_orphans
|
|
138
|
+
Remove blob files that have no metadata and are over a day old.
|
|
139
|
+
"""
|
|
140
|
+
_open_existing(data_dir)
|
|
141
|
+
blobs = BlobStore(data_dir)
|
|
142
|
+
report = asyncio.run(_with_db(data_dir, _run_fsck, blobs))
|
|
143
|
+
for label, blob_ids in (
|
|
144
|
+
("orphan", report.orphans),
|
|
145
|
+
("recent orphan (may be in flight)", report.young_orphans),
|
|
146
|
+
("missing", report.missing),
|
|
147
|
+
("wrong size", report.wrong_size),
|
|
148
|
+
):
|
|
149
|
+
for blob_id in blob_ids:
|
|
150
|
+
print(f"{label}: {blob_id}")
|
|
151
|
+
if delete_orphans:
|
|
152
|
+
for blob_id in report.orphans:
|
|
153
|
+
blobs.delete(blob_id)
|
|
154
|
+
print(f"deleted {len(report.orphans)} orphans")
|
|
155
|
+
if report.missing or report.wrong_size or (report.orphans and not delete_orphans):
|
|
156
|
+
sys.exit(1)
|
|
157
|
+
print("clean")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
async def _run_fsck(db: Database, blobs: BlobStore) -> maintenance.FsckReport:
|
|
161
|
+
return await maintenance.fsck(db, blobs, time.time())
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _prepare(data_dir: Path) -> None:
|
|
165
|
+
"""Create the directory layout and schema, before any worker can start."""
|
|
166
|
+
data_dir.mkdir(parents=True, exist_ok=True)
|
|
167
|
+
data_dir.chmod(0o700) # meta.db holds secrets, even if the directory existed.
|
|
168
|
+
BlobStore(data_dir).initialize()
|
|
169
|
+
meta.initialize(data_dir / asgi.META_FILE)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _open_existing(data_dir: Path) -> None:
|
|
173
|
+
"""Refuse a path that holds no store: reporting on it would be false reassurance."""
|
|
174
|
+
if not (data_dir / asgi.META_FILE).exists():
|
|
175
|
+
sys.exit(f"theblob: no store found in {data_dir}")
|
|
176
|
+
meta.initialize(data_dir / asgi.META_FILE)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
async def _with_db[**P, R](
|
|
180
|
+
data_dir: Path,
|
|
181
|
+
function: Callable[Concatenate[Database, P], Awaitable[R]],
|
|
182
|
+
*args: P.args,
|
|
183
|
+
**kwargs: P.kwargs,
|
|
184
|
+
) -> R:
|
|
185
|
+
db = Database(data_dir / asgi.META_FILE)
|
|
186
|
+
try:
|
|
187
|
+
return await function(db, *args, **kwargs)
|
|
188
|
+
finally:
|
|
189
|
+
db.close()
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
async def _create_key(data_dir: Path) -> tuple[str, str]:
|
|
193
|
+
access_key = "".join(
|
|
194
|
+
secrets.choice(_ACCESS_KEY_ALPHABET) for _ in range(_ACCESS_KEY_LENGTH)
|
|
195
|
+
)
|
|
196
|
+
secret = secrets.token_urlsafe(_SECRET_BYTES)
|
|
197
|
+
await _with_db(data_dir, meta.add_access_key, access_key, secret)
|
|
198
|
+
return access_key, secret
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
async def _create_first_key_if_needed(data_dir: Path) -> None:
|
|
202
|
+
"""Make a fresh store usable at once: no key means nobody could ever connect."""
|
|
203
|
+
if await _with_db(data_dir, meta.list_access_keys):
|
|
204
|
+
return
|
|
205
|
+
access_key, secret = await _create_key(data_dir)
|
|
206
|
+
print(
|
|
207
|
+
"Created the first access key; the secret isn't shown again:\n"
|
|
208
|
+
f" access key: {access_key}\n"
|
|
209
|
+
f" secret key: {secret}",
|
|
210
|
+
flush=True,
|
|
211
|
+
)
|