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/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
+ )