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/cli.py
ADDED
|
@@ -0,0 +1,499 @@
|
|
|
1
|
+
"""The `leblob` 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 leblob import asgi, maintenance, meta, metrics
|
|
19
|
+
from leblob.blobstore import (
|
|
20
|
+
VOLUME_FILE,
|
|
21
|
+
BlobStore,
|
|
22
|
+
Volume,
|
|
23
|
+
VolumeError,
|
|
24
|
+
create_volume,
|
|
25
|
+
open_store,
|
|
26
|
+
open_volume,
|
|
27
|
+
)
|
|
28
|
+
from leblob.meta import Database
|
|
29
|
+
from leblob.s3.access import Level
|
|
30
|
+
from leblob.s3.errors import S3Error
|
|
31
|
+
|
|
32
|
+
if TYPE_CHECKING:
|
|
33
|
+
from collections.abc import Awaitable, Callable
|
|
34
|
+
from typing import Concatenate
|
|
35
|
+
|
|
36
|
+
from leblob.s3.access import Scope
|
|
37
|
+
|
|
38
|
+
_DEFAULT_DATA_DIR = Path("./data")
|
|
39
|
+
_ACCESS_KEY_ALPHABET = string.ascii_uppercase + string.digits
|
|
40
|
+
_ACCESS_KEY_LENGTH = 20
|
|
41
|
+
_SECRET_BYTES = 30
|
|
42
|
+
_FOLLOWS_IN = "a couple of minutes"
|
|
43
|
+
|
|
44
|
+
DataDir = Annotated[Path, cyclopts.Parameter(help="Directory holding all stored data.")]
|
|
45
|
+
GrantLevel = Annotated[Level, cyclopts.Parameter(help="read, write or admin.")]
|
|
46
|
+
|
|
47
|
+
app = cyclopts.App(name="leblob", help="An S3-compatible object store.")
|
|
48
|
+
key_app = cyclopts.App(name="key", help="Manage access keys.")
|
|
49
|
+
app.command(key_app)
|
|
50
|
+
volume_app = cyclopts.App(name="volume", help="Manage the disks blobs are stored on.")
|
|
51
|
+
app.command(volume_app)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@cyclopts.Parameter(name="*")
|
|
55
|
+
@dataclass(frozen=True, kw_only=True)
|
|
56
|
+
class ServeOptions:
|
|
57
|
+
"""Options of `leblob serve`.
|
|
58
|
+
|
|
59
|
+
Parameters
|
|
60
|
+
----------
|
|
61
|
+
data_dir
|
|
62
|
+
Directory holding all stored data.
|
|
63
|
+
host
|
|
64
|
+
Address to listen on.
|
|
65
|
+
port
|
|
66
|
+
Port to listen on.
|
|
67
|
+
workers
|
|
68
|
+
Number of worker processes; defaults to the number of CPUs, 4 at most. Each
|
|
69
|
+
takes about 30 MiB. Writes wait on the disk and gain nothing from more; reads
|
|
70
|
+
do.
|
|
71
|
+
domain
|
|
72
|
+
Base domain enabling virtual-hosted addressing (bucket.domain).
|
|
73
|
+
ssl_certfile
|
|
74
|
+
TLS certificate, to serve HTTPS directly.
|
|
75
|
+
ssl_keyfile
|
|
76
|
+
TLS private key.
|
|
77
|
+
shutdown_grace
|
|
78
|
+
Seconds to let requests finish on shutdown. Without a limit, one client
|
|
79
|
+
that stops reading would prevent the server from ever exiting.
|
|
80
|
+
allow_sse_c_over_http
|
|
81
|
+
Accept encryption keys over plain HTTP. Only behind a proxy that terminates TLS,
|
|
82
|
+
or for tests: otherwise the key can be read off the network.
|
|
83
|
+
lifecycle_day_seconds
|
|
84
|
+
How long a day lasts for lifecycle rules. For tests, which can't wait for one.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
data_dir: Path = _DEFAULT_DATA_DIR
|
|
88
|
+
host: str = "127.0.0.1"
|
|
89
|
+
port: int = 9000
|
|
90
|
+
workers: int | None = None
|
|
91
|
+
domain: str | None = None
|
|
92
|
+
ssl_certfile: Path | None = None
|
|
93
|
+
ssl_keyfile: Path | None = None
|
|
94
|
+
shutdown_grace: int = 20
|
|
95
|
+
lifecycle_day_seconds: int = meta.DAY_MS // 1000
|
|
96
|
+
allow_sse_c_over_http: bool = False
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
_DEFAULT_MOST_WORKERS = 4
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@app.command
|
|
103
|
+
def serve(options: ServeOptions | None = None) -> None:
|
|
104
|
+
"""Run the S3 server."""
|
|
105
|
+
options = options or ServeOptions()
|
|
106
|
+
_prepare(options.data_dir)
|
|
107
|
+
# Fail here, not in every worker. With several copies, it can run without a volume.
|
|
108
|
+
_open_store(options.data_dir, allow_degraded=_copies(options.data_dir) > 1)
|
|
109
|
+
asyncio.run(_create_first_key_if_needed(options.data_dir))
|
|
110
|
+
|
|
111
|
+
metrics.clear(options.data_dir)
|
|
112
|
+
os.environ[asgi.ENV_DATA_DIR] = str(options.data_dir.resolve())
|
|
113
|
+
os.environ[asgi.ENV_LIFECYCLE_DAY_SECONDS] = str(options.lifecycle_day_seconds)
|
|
114
|
+
if options.allow_sse_c_over_http:
|
|
115
|
+
os.environ[asgi.ENV_SSE_OVER_HTTP] = "1"
|
|
116
|
+
else:
|
|
117
|
+
os.environ.pop(asgi.ENV_SSE_OVER_HTTP, None)
|
|
118
|
+
if options.domain:
|
|
119
|
+
os.environ[asgi.ENV_BASE_DOMAIN] = options.domain
|
|
120
|
+
else:
|
|
121
|
+
os.environ.pop(asgi.ENV_BASE_DOMAIN, None)
|
|
122
|
+
|
|
123
|
+
uvicorn.run(
|
|
124
|
+
"leblob.asgi:create_app_from_env",
|
|
125
|
+
factory=True,
|
|
126
|
+
host=options.host,
|
|
127
|
+
port=options.port,
|
|
128
|
+
workers=options.workers or min(os.cpu_count() or 1, _DEFAULT_MOST_WORKERS),
|
|
129
|
+
http="httptools",
|
|
130
|
+
loop="uvloop",
|
|
131
|
+
lifespan="on",
|
|
132
|
+
ws="none",
|
|
133
|
+
access_log=False,
|
|
134
|
+
ssl_certfile=options.ssl_certfile,
|
|
135
|
+
ssl_keyfile=options.ssl_keyfile,
|
|
136
|
+
timeout_graceful_shutdown=options.shutdown_grace,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@key_app.command(name="create")
|
|
141
|
+
def key_create(*, data_dir: DataDir = _DEFAULT_DATA_DIR, scoped: bool = False) -> None:
|
|
142
|
+
"""Create an access key and print it. The secret isn't shown again.
|
|
143
|
+
|
|
144
|
+
Parameters
|
|
145
|
+
----------
|
|
146
|
+
scoped
|
|
147
|
+
Give the key no rights until `key grant` gives it some, instead of full access.
|
|
148
|
+
"""
|
|
149
|
+
_prepare(data_dir)
|
|
150
|
+
access_key, secret = asyncio.run(_create_key(data_dir, scoped=scoped))
|
|
151
|
+
print(f"access key: {access_key}")
|
|
152
|
+
print(f"secret key: {secret}")
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
@key_app.command(name="list")
|
|
156
|
+
def key_list(*, data_dir: DataDir = _DEFAULT_DATA_DIR) -> None:
|
|
157
|
+
"""List access keys and what each may do."""
|
|
158
|
+
_open_existing(data_dir)
|
|
159
|
+
keys = asyncio.run(_with_db(data_dir, meta.list_access_keys))
|
|
160
|
+
for access_key, scope in keys.items():
|
|
161
|
+
print(f"{access_key} {_describe_scope(scope)}")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _describe_scope(scope: Scope) -> str:
|
|
165
|
+
if scope.buckets is None:
|
|
166
|
+
return "full access"
|
|
167
|
+
grants = ", ".join(
|
|
168
|
+
f"{bucket}: {level.name.lower()}"
|
|
169
|
+
for bucket, level in sorted(scope.buckets.items())
|
|
170
|
+
)
|
|
171
|
+
return grants or "no rights"
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@key_app.command(name="grant")
|
|
175
|
+
def key_grant(
|
|
176
|
+
access_key: str,
|
|
177
|
+
bucket: str,
|
|
178
|
+
level: GrantLevel,
|
|
179
|
+
/,
|
|
180
|
+
*,
|
|
181
|
+
data_dir: DataDir = _DEFAULT_DATA_DIR,
|
|
182
|
+
) -> None:
|
|
183
|
+
"""Give a scoped key a level on a bucket, replacing any it had."""
|
|
184
|
+
_open_existing(data_dir)
|
|
185
|
+
try:
|
|
186
|
+
asyncio.run(_with_db(data_dir, meta.grant_bucket, access_key, bucket, level))
|
|
187
|
+
except S3Error as error:
|
|
188
|
+
sys.exit(f"leblob: {access_key} on {bucket}: {error.message}")
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
@key_app.command(name="revoke")
|
|
192
|
+
def key_revoke(
|
|
193
|
+
access_key: str, bucket: str, /, *, data_dir: DataDir = _DEFAULT_DATA_DIR
|
|
194
|
+
) -> None:
|
|
195
|
+
"""Remove what a scoped key was granted on a bucket."""
|
|
196
|
+
_open_existing(data_dir)
|
|
197
|
+
if not asyncio.run(_with_db(data_dir, meta.revoke_bucket, access_key, bucket)):
|
|
198
|
+
sys.exit(f"leblob: {access_key} has no grant on {bucket}")
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@key_app.command(name="delete")
|
|
202
|
+
def key_delete(access_key: str, /, *, data_dir: DataDir = _DEFAULT_DATA_DIR) -> None:
|
|
203
|
+
"""Delete an access key."""
|
|
204
|
+
_open_existing(data_dir)
|
|
205
|
+
if not asyncio.run(_with_db(data_dir, meta.delete_access_key, access_key)):
|
|
206
|
+
sys.exit(f"leblob: no such access key: {access_key}")
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@app.command
|
|
210
|
+
def fsck(
|
|
211
|
+
*, data_dir: DataDir = _DEFAULT_DATA_DIR, delete_orphans: bool = False
|
|
212
|
+
) -> None:
|
|
213
|
+
"""Compare the metadata with the disk; exit non-zero if they disagree.
|
|
214
|
+
|
|
215
|
+
Parameters
|
|
216
|
+
----------
|
|
217
|
+
delete_orphans
|
|
218
|
+
Remove blob files that have no metadata and are over a day old.
|
|
219
|
+
"""
|
|
220
|
+
_open_existing(data_dir)
|
|
221
|
+
blobs = _open_store(data_dir)
|
|
222
|
+
report = asyncio.run(_with_db(data_dir, _run_fsck, blobs))
|
|
223
|
+
if report.under_replicated:
|
|
224
|
+
print(f"blobs with too few copies: {len(report.under_replicated)}")
|
|
225
|
+
for blob_id in report.lost:
|
|
226
|
+
print(f"lost, no copy left: {blob_id}")
|
|
227
|
+
for label, files in (
|
|
228
|
+
("orphan", report.orphans),
|
|
229
|
+
("recent orphan (may be in flight)", report.young_orphans),
|
|
230
|
+
("missing", report.missing),
|
|
231
|
+
("wrong size", report.wrong_size),
|
|
232
|
+
):
|
|
233
|
+
for file in files:
|
|
234
|
+
print(f"{label}: {file.blob_id} on volume {file.volume_id}")
|
|
235
|
+
if delete_orphans:
|
|
236
|
+
for file in report.orphans:
|
|
237
|
+
blobs.delete(file.blob_id, (file.volume_id,))
|
|
238
|
+
print(f"deleted {len(report.orphans)} orphans")
|
|
239
|
+
damaged = report.missing or report.wrong_size or report.lost
|
|
240
|
+
if damaged or (report.orphans and not delete_orphans):
|
|
241
|
+
sys.exit(1)
|
|
242
|
+
print("clean")
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
@volume_app.command(name="add")
|
|
246
|
+
def volume_add(path: Path, /, *, data_dir: DataDir = _DEFAULT_DATA_DIR) -> None:
|
|
247
|
+
"""Add a volume for new blobs. A running server follows within a couple of minutes.
|
|
248
|
+
|
|
249
|
+
With one copy of each blob, losing a volume loses the objects with blobs on it:
|
|
250
|
+
see `volume copies`.
|
|
251
|
+
|
|
252
|
+
Parameters
|
|
253
|
+
----------
|
|
254
|
+
path
|
|
255
|
+
An existing empty directory, usually the mount point of a disk.
|
|
256
|
+
"""
|
|
257
|
+
_open_existing(data_dir)
|
|
258
|
+
root = path.resolve()
|
|
259
|
+
identity = secrets.token_hex(16)
|
|
260
|
+
try:
|
|
261
|
+
create_volume(root, identity)
|
|
262
|
+
except (VolumeError, OSError) as error:
|
|
263
|
+
sys.exit(f"leblob: {error}")
|
|
264
|
+
volume_id = asyncio.run(_with_db(data_dir, meta.add_volume, identity, str(root)))
|
|
265
|
+
print(f"added volume {volume_id} at {root}; a server uses it within {_FOLLOWS_IN}")
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
@volume_app.command(name="copies")
|
|
269
|
+
def volume_copies(count: int, /, *, data_dir: DataDir = _DEFAULT_DATA_DIR) -> None:
|
|
270
|
+
"""Set how many volumes hold each blob. A server follows within a couple of minutes.
|
|
271
|
+
|
|
272
|
+
A running server adds the missing copies of blobs already stored, and leaves extra
|
|
273
|
+
ones in place. Each copy needs its own disk to protect against losing one.
|
|
274
|
+
|
|
275
|
+
Parameters
|
|
276
|
+
----------
|
|
277
|
+
count
|
|
278
|
+
Copies of each new blob, at most the number of volumes.
|
|
279
|
+
"""
|
|
280
|
+
_open_existing(data_dir)
|
|
281
|
+
records = meta.load_volumes(data_dir / asgi.META_FILE)
|
|
282
|
+
if not 1 <= count <= len(records):
|
|
283
|
+
sys.exit(f"leblob: {count} copies can't fit on {len(records)} volumes")
|
|
284
|
+
asyncio.run(_with_db(data_dir, meta.set_copies, count))
|
|
285
|
+
print(f"blobs get {count} copies; a server uses it within {_FOLLOWS_IN}")
|
|
286
|
+
devices = {(data_dir / record.path).stat().st_dev for record in records}
|
|
287
|
+
if len(devices) < count:
|
|
288
|
+
print(
|
|
289
|
+
f"warning: the volumes are on {len(devices)} filesystems, fewer than that"
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
@volume_app.command(name="list")
|
|
294
|
+
def volume_list(*, data_dir: DataDir = _DEFAULT_DATA_DIR) -> None:
|
|
295
|
+
"""List volumes, with how full each is and any that can't be used."""
|
|
296
|
+
_open_existing(data_dir)
|
|
297
|
+
print(f"blobs get {_copies(data_dir)} copies")
|
|
298
|
+
print(f"repairs queued: {asyncio.run(_with_db(data_dir, meta.repairs_queued))}")
|
|
299
|
+
counts = asyncio.run(_with_db(data_dir, meta.blobs_per_volume))
|
|
300
|
+
for record in meta.load_volumes(data_dir / asgi.META_FILE):
|
|
301
|
+
count = counts.get(record.volume_id, 0)
|
|
302
|
+
try:
|
|
303
|
+
free = open_volume(data_dir, record).free_bytes() / 2**30
|
|
304
|
+
except VolumeError as error:
|
|
305
|
+
print(f"{record.volume_id} {record.path} {count} blobs {error}")
|
|
306
|
+
else:
|
|
307
|
+
state = " draining" if record.draining else ""
|
|
308
|
+
print(
|
|
309
|
+
f"{record.volume_id} {record.path} {count} blobs"
|
|
310
|
+
f" {free:.1f} GiB free{state}"
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
@volume_app.command(name="drain")
|
|
315
|
+
def volume_drain(volume_id: int, /, *, data_dir: DataDir = _DEFAULT_DATA_DIR) -> None:
|
|
316
|
+
"""Stop using a volume, and have a running server move its blobs elsewhere.
|
|
317
|
+
|
|
318
|
+
`volume list` shows when it is empty, and `volume forget` then removes it.
|
|
319
|
+
"""
|
|
320
|
+
_open_existing(data_dir)
|
|
321
|
+
try:
|
|
322
|
+
asyncio.run(_with_db(data_dir, meta.drain_volume, volume_id))
|
|
323
|
+
except meta.VolumeChangeError as error:
|
|
324
|
+
sys.exit(f"leblob: {error}")
|
|
325
|
+
print(f"draining volume {volume_id}")
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
@volume_app.command(name="forget")
|
|
329
|
+
def volume_forget(
|
|
330
|
+
volume_id: int,
|
|
331
|
+
/,
|
|
332
|
+
*,
|
|
333
|
+
data_dir: DataDir = _DEFAULT_DATA_DIR,
|
|
334
|
+
disk_lost: bool = False,
|
|
335
|
+
accept_loss: bool = False,
|
|
336
|
+
) -> None:
|
|
337
|
+
"""Remove a volume that is empty, or whose disk is gone for good.
|
|
338
|
+
|
|
339
|
+
Parameters
|
|
340
|
+
----------
|
|
341
|
+
disk_lost
|
|
342
|
+
The disk is gone: forget the copies on it, for a running server to replace.
|
|
343
|
+
accept_loss
|
|
344
|
+
Go ahead even though some blobs have no other copy, losing their objects.
|
|
345
|
+
"""
|
|
346
|
+
_open_existing(data_dir)
|
|
347
|
+
try:
|
|
348
|
+
asyncio.run(
|
|
349
|
+
_with_db(
|
|
350
|
+
data_dir,
|
|
351
|
+
meta.forget_volume,
|
|
352
|
+
volume_id,
|
|
353
|
+
disk_lost=disk_lost,
|
|
354
|
+
accept_loss=accept_loss,
|
|
355
|
+
)
|
|
356
|
+
)
|
|
357
|
+
except meta.VolumeChangeError as error:
|
|
358
|
+
sys.exit(f"leblob: {error}")
|
|
359
|
+
print(f"forgot volume {volume_id}")
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
@app.command
|
|
363
|
+
def scrub(*, data_dir: DataDir = _DEFAULT_DATA_DIR) -> None:
|
|
364
|
+
"""Check every copy against its MD5 now; a running server does this slowly anyway.
|
|
365
|
+
|
|
366
|
+
A bad copy is removed, and a running server replaces it. Exits non-zero if a blob's
|
|
367
|
+
only copy is bad.
|
|
368
|
+
"""
|
|
369
|
+
_open_existing(data_dir)
|
|
370
|
+
blobs = _open_store(data_dir, allow_degraded=True)
|
|
371
|
+
report = asyncio.run(_with_db(data_dir, maintenance.scrub_everything, blobs))
|
|
372
|
+
print(f"checked {report.checked} copies, removed {report.removed} bad ones")
|
|
373
|
+
for blob_id in report.unrecoverable:
|
|
374
|
+
print(f"only copy is bad: {blob_id}")
|
|
375
|
+
if report.unrecoverable:
|
|
376
|
+
sys.exit(1)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
@app.command
|
|
380
|
+
def backup(destination: Path, /, *, data_dir: DataDir = _DEFAULT_DATA_DIR) -> None:
|
|
381
|
+
"""Copy the metadata to a new file, while the server may be running.
|
|
382
|
+
|
|
383
|
+
The file holds the access keys' secrets. Without it, losing the metadata loses
|
|
384
|
+
every object, because blob files have random names. It doesn't hold the blobs.
|
|
385
|
+
|
|
386
|
+
Parameters
|
|
387
|
+
----------
|
|
388
|
+
destination
|
|
389
|
+
File to create. Put it on another disk.
|
|
390
|
+
"""
|
|
391
|
+
_open_existing(data_dir)
|
|
392
|
+
try:
|
|
393
|
+
meta.backup(data_dir / asgi.META_FILE, destination)
|
|
394
|
+
except (meta.BackupError, OSError) as error:
|
|
395
|
+
sys.exit(f"leblob: {error}")
|
|
396
|
+
print(f"backed up to {destination}")
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
@app.command
|
|
400
|
+
def restore(backup_file: Path, /, *, data_dir: DataDir = _DEFAULT_DATA_DIR) -> None:
|
|
401
|
+
"""Put a backup in place of lost metadata. Stop the server first.
|
|
402
|
+
|
|
403
|
+
Objects written since the backup are lost, and their files become orphans. Blobs
|
|
404
|
+
collected since then are missing, which can only happen once the backup is older
|
|
405
|
+
than the collector's grace period, an hour. `fsck` reports both.
|
|
406
|
+
|
|
407
|
+
Parameters
|
|
408
|
+
----------
|
|
409
|
+
backup_file
|
|
410
|
+
File made by `leblob backup`.
|
|
411
|
+
"""
|
|
412
|
+
data_dir.mkdir(parents=True, exist_ok=True)
|
|
413
|
+
data_dir.chmod(0o700)
|
|
414
|
+
try:
|
|
415
|
+
meta.restore(backup_file, data_dir / asgi.META_FILE)
|
|
416
|
+
except (meta.BackupError, OSError) as error:
|
|
417
|
+
sys.exit(f"leblob: {error}")
|
|
418
|
+
_prepare(data_dir) # An older backup needs migrating, and a new disk its layout.
|
|
419
|
+
_open_store(data_dir) # A backup of another store must not run on these files.
|
|
420
|
+
print(f"restored from {backup_file}; run `leblob fsck` to compare it with the disk")
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
async def _run_fsck(db: Database, blobs: BlobStore) -> maintenance.FsckReport:
|
|
424
|
+
return await maintenance.fsck(db, blobs, time.time())
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _prepare(data_dir: Path) -> None:
|
|
428
|
+
"""Create the directory layout and schema, before any worker can start."""
|
|
429
|
+
data_dir.mkdir(parents=True, exist_ok=True)
|
|
430
|
+
data_dir.chmod(0o700) # meta.db holds secrets, even if the directory existed.
|
|
431
|
+
meta.initialize(data_dir / asgi.META_FILE)
|
|
432
|
+
_prepare_first_volume(data_dir)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _open_existing(data_dir: Path) -> None:
|
|
436
|
+
"""Refuse a path that holds no store: reporting on it would be false reassurance."""
|
|
437
|
+
if not (data_dir / asgi.META_FILE).exists():
|
|
438
|
+
sys.exit(f"leblob: no store found in {data_dir}")
|
|
439
|
+
meta.initialize(data_dir / asgi.META_FILE)
|
|
440
|
+
_prepare_first_volume(data_dir)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _prepare_first_volume(data_dir: Path) -> None:
|
|
444
|
+
"""Lay out the data directory, whose blobs need no disk of their own to appear."""
|
|
445
|
+
first = meta.load_volumes(data_dir / asgi.META_FILE)[0]
|
|
446
|
+
if not (data_dir / VOLUME_FILE).exists():
|
|
447
|
+
volume = Volume(data_dir, first.volume_id)
|
|
448
|
+
volume.initialize()
|
|
449
|
+
volume.claim(first.identity)
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _copies(data_dir: Path) -> int:
|
|
453
|
+
return meta.load_copies(data_dir / asgi.META_FILE)
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _open_store(data_dir: Path, *, allow_degraded: bool = False) -> BlobStore:
|
|
457
|
+
"""Refuse volumes that aren't what the metadata expects, and say which."""
|
|
458
|
+
records = meta.load_volumes(data_dir / asgi.META_FILE)
|
|
459
|
+
try:
|
|
460
|
+
return open_store(
|
|
461
|
+
data_dir, records, _copies(data_dir), allow_degraded=allow_degraded
|
|
462
|
+
)
|
|
463
|
+
except VolumeError as error:
|
|
464
|
+
sys.exit(f"leblob: {error}")
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
async def _with_db[**P, R](
|
|
468
|
+
data_dir: Path,
|
|
469
|
+
function: Callable[Concatenate[Database, P], Awaitable[R]],
|
|
470
|
+
*args: P.args,
|
|
471
|
+
**kwargs: P.kwargs,
|
|
472
|
+
) -> R:
|
|
473
|
+
db = Database(data_dir / asgi.META_FILE)
|
|
474
|
+
try:
|
|
475
|
+
return await function(db, *args, **kwargs)
|
|
476
|
+
finally:
|
|
477
|
+
db.close()
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
async def _create_key(data_dir: Path, *, scoped: bool = False) -> tuple[str, str]:
|
|
481
|
+
access_key = "".join(
|
|
482
|
+
secrets.choice(_ACCESS_KEY_ALPHABET) for _ in range(_ACCESS_KEY_LENGTH)
|
|
483
|
+
)
|
|
484
|
+
secret = secrets.token_urlsafe(_SECRET_BYTES)
|
|
485
|
+
await _with_db(data_dir, meta.add_access_key, access_key, secret, scoped=scoped)
|
|
486
|
+
return access_key, secret
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
async def _create_first_key_if_needed(data_dir: Path) -> None:
|
|
490
|
+
"""Make a fresh store usable at once: no key means nobody could ever connect."""
|
|
491
|
+
if await _with_db(data_dir, meta.list_access_keys):
|
|
492
|
+
return
|
|
493
|
+
access_key, secret = await _create_key(data_dir)
|
|
494
|
+
print(
|
|
495
|
+
"Created the first access key; the secret isn't shown again:\n"
|
|
496
|
+
f" access key: {access_key}\n"
|
|
497
|
+
f" secret key: {secret}",
|
|
498
|
+
flush=True,
|
|
499
|
+
)
|