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/metrics.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Counters for `/-/metrics`, added up across worker processes through files.
|
|
2
|
+
|
|
3
|
+
A scrape reaches one worker, chosen by the kernel, so what each counts alone would jump
|
|
4
|
+
around between scrapes. Instead each worker writes its counters to a file every few
|
|
5
|
+
seconds and on each scrape, and the one answering adds up the files. A worker that dies
|
|
6
|
+
leaves its file, so totals never go down until the server restarts, when Prometheus
|
|
7
|
+
expects it.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
import shutil
|
|
17
|
+
import threading
|
|
18
|
+
from bisect import bisect_left
|
|
19
|
+
from collections import defaultdict
|
|
20
|
+
from contextlib import suppress
|
|
21
|
+
from dataclasses import dataclass, field
|
|
22
|
+
from typing import TYPE_CHECKING
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from collections.abc import Iterable, Mapping
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
|
|
28
|
+
type Labels = tuple[tuple[str, str], ...]
|
|
29
|
+
type Sample = tuple[str, Labels, float]
|
|
30
|
+
|
|
31
|
+
logger = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
_DIRECTORY = "metrics"
|
|
34
|
+
_FLUSH_SECONDS = 5
|
|
35
|
+
_REQUESTS = "leblob_http_requests_total"
|
|
36
|
+
_LATENCY = "leblob_http_request_seconds"
|
|
37
|
+
_LATENCY_BUCKETS = (0.005, 0.01, 0.05, 0.1, 0.5, 1.0, 5.0, 30.0)
|
|
38
|
+
|
|
39
|
+
BLOBS_COLLECTED = "leblob_blobs_collected_total"
|
|
40
|
+
COPIES_REPAIRED = "leblob_copies_repaired_total"
|
|
41
|
+
COPIES_SCRUBBED = "leblob_copies_scrubbed_total"
|
|
42
|
+
BAD_COPIES_REMOVED = "leblob_bad_copies_removed_total"
|
|
43
|
+
LIFECYCLE_OBJECTS_EXPIRED = "leblob_lifecycle_objects_expired_total"
|
|
44
|
+
LIFECYCLE_VERSIONS_ERASED = "leblob_lifecycle_versions_erased_total"
|
|
45
|
+
LIFECYCLE_UPLOADS_ABORTED = "leblob_lifecycle_uploads_aborted_total"
|
|
46
|
+
ONLY_COPIES_BAD = "leblob_only_copies_bad_total"
|
|
47
|
+
VOLUME_FAILURES = "leblob_volume_failures_total"
|
|
48
|
+
REPAIRS_QUEUED = "leblob_repairs_queued"
|
|
49
|
+
BLOBS_AWAITING_COLLECTION = "leblob_blobs_awaiting_collection"
|
|
50
|
+
COPIES = "leblob_copies_wanted"
|
|
51
|
+
VOLUME_UP = "leblob_volume_up"
|
|
52
|
+
VOLUME_DRAINING = "leblob_volume_draining"
|
|
53
|
+
VOLUME_FREE_BYTES = "leblob_volume_free_bytes"
|
|
54
|
+
|
|
55
|
+
_FAMILIES: Mapping[str, tuple[str, str]] = {
|
|
56
|
+
_REQUESTS: ("counter", "Requests answered, by S3 operation and status."),
|
|
57
|
+
_LATENCY: ("histogram", "Seconds to answer a request, by S3 operation."),
|
|
58
|
+
BLOBS_COLLECTED: ("counter", "Blobs deleted after their grace period."),
|
|
59
|
+
COPIES_REPAIRED: ("counter", "Copies of blobs restored by repair."),
|
|
60
|
+
COPIES_SCRUBBED: ("counter", "Copies checked against their MD5."),
|
|
61
|
+
BAD_COPIES_REMOVED: ("counter", "Bad copies scrub removed for repair to replace."),
|
|
62
|
+
ONLY_COPIES_BAD: ("counter", "Times scrub found a blob's only copy bad."),
|
|
63
|
+
VOLUME_FAILURES: ("counter", "I/O errors on a volume."),
|
|
64
|
+
LIFECYCLE_OBJECTS_EXPIRED: ("counter", "Objects expired by lifecycle rules."),
|
|
65
|
+
LIFECYCLE_VERSIONS_ERASED: ("counter", "Versions erased by lifecycle rules."),
|
|
66
|
+
LIFECYCLE_UPLOADS_ABORTED: ("counter", "Uploads aborted by lifecycle rules."),
|
|
67
|
+
REPAIRS_QUEUED: ("gauge", "Blobs waiting for repair."),
|
|
68
|
+
BLOBS_AWAITING_COLLECTION: ("gauge", "Blobs queued for deletion."),
|
|
69
|
+
COPIES: ("gauge", "Copies each blob should have."),
|
|
70
|
+
VOLUME_UP: ("gauge", "Whether a volume can be used, 1 or 0."),
|
|
71
|
+
VOLUME_DRAINING: ("gauge", "Whether a volume is being emptied, 1 or 0."),
|
|
72
|
+
VOLUME_FREE_BYTES: ("gauge", "Bytes free on a volume."),
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(slots=True)
|
|
77
|
+
class _Latency:
|
|
78
|
+
"""One operation's histogram, kept in separate buckets until a scrape adds them."""
|
|
79
|
+
|
|
80
|
+
buckets: list[int] = field(
|
|
81
|
+
default_factory=lambda: [0] * (len(_LATENCY_BUCKETS) + 1)
|
|
82
|
+
)
|
|
83
|
+
seconds: float = 0.0
|
|
84
|
+
count: int = 0
|
|
85
|
+
|
|
86
|
+
def samples(self, operation: str) -> list[Sample]:
|
|
87
|
+
result: list[Sample] = []
|
|
88
|
+
below = 0
|
|
89
|
+
for bound, seen in zip((*_LATENCY_BUCKETS, "+Inf"), self.buckets, strict=True):
|
|
90
|
+
below += seen
|
|
91
|
+
labels = (("le", str(bound)), ("operation", operation))
|
|
92
|
+
result.append((f"{_LATENCY}_bucket", labels, below))
|
|
93
|
+
by_operation = (("operation", operation),)
|
|
94
|
+
return [
|
|
95
|
+
*result,
|
|
96
|
+
(f"{_LATENCY}_sum", by_operation, self.seconds),
|
|
97
|
+
(f"{_LATENCY}_count", by_operation, self.count),
|
|
98
|
+
]
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class Counters:
|
|
102
|
+
"""This process's counters. Worker threads and the event loop both add to them."""
|
|
103
|
+
|
|
104
|
+
def __init__(self) -> None:
|
|
105
|
+
self._values: dict[tuple[str, Labels], float] = defaultdict(float)
|
|
106
|
+
self._requests: dict[tuple[str, int], int] = defaultdict(int)
|
|
107
|
+
self._latency: dict[str, _Latency] = defaultdict(_Latency)
|
|
108
|
+
self._lock = threading.Lock()
|
|
109
|
+
|
|
110
|
+
def add(self, name: str, amount: float = 1, **labels: str) -> None:
|
|
111
|
+
key = name, tuple(sorted(labels.items()))
|
|
112
|
+
with self._lock:
|
|
113
|
+
self._values[key] += amount
|
|
114
|
+
|
|
115
|
+
def observe_request(self, operation: str, status: int, seconds: float) -> None:
|
|
116
|
+
"""Count a request. On every request, so this costs little until a scrape."""
|
|
117
|
+
bucket = bisect_left(_LATENCY_BUCKETS, seconds)
|
|
118
|
+
with self._lock:
|
|
119
|
+
self._requests[operation, status] += 1
|
|
120
|
+
latency = self._latency[operation]
|
|
121
|
+
latency.buckets[bucket] += 1
|
|
122
|
+
latency.seconds += seconds
|
|
123
|
+
latency.count += 1
|
|
124
|
+
|
|
125
|
+
def samples(self) -> list[Sample]:
|
|
126
|
+
with self._lock:
|
|
127
|
+
samples: list[Sample] = [
|
|
128
|
+
(name, labels, value) for (name, labels), value in self._values.items()
|
|
129
|
+
]
|
|
130
|
+
for (operation, status), count in self._requests.items():
|
|
131
|
+
labels = (("operation", operation), ("status", str(status)))
|
|
132
|
+
samples.append((_REQUESTS, labels, count))
|
|
133
|
+
for operation, latency in self._latency.items():
|
|
134
|
+
samples += latency.samples(operation)
|
|
135
|
+
return samples
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
counters = Counters()
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def clear(data_dir: Path) -> None:
|
|
142
|
+
"""Forget the workers of an earlier run, before any worker starts."""
|
|
143
|
+
shutil.rmtree(data_dir / _DIRECTORY, ignore_errors=True)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _worker_file(data_dir: Path) -> Path:
|
|
147
|
+
return data_dir / _DIRECTORY / f"{os.getpid()}.json"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def flush(data_dir: Path) -> None:
|
|
151
|
+
"""Replace this worker's file. It needn't survive a crash, so there is no fsync."""
|
|
152
|
+
path = _worker_file(data_dir)
|
|
153
|
+
path.parent.mkdir(exist_ok=True)
|
|
154
|
+
partial = path.with_suffix(".partial")
|
|
155
|
+
partial.write_text(json.dumps(counters.samples()), encoding="utf-8")
|
|
156
|
+
partial.rename(path)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
async def run_flusher(data_dir: Path) -> None:
|
|
160
|
+
"""Write this worker's counters regularly. A full disk mustn't stop it for good."""
|
|
161
|
+
try:
|
|
162
|
+
while True:
|
|
163
|
+
await asyncio.sleep(_FLUSH_SECONDS)
|
|
164
|
+
try:
|
|
165
|
+
await asyncio.to_thread(flush, data_dir)
|
|
166
|
+
except OSError:
|
|
167
|
+
logger.exception("Can't write metrics; they will be written later.")
|
|
168
|
+
finally:
|
|
169
|
+
# Shutting down: the last few seconds count too.
|
|
170
|
+
with suppress(OSError):
|
|
171
|
+
flush(data_dir)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def all_workers(data_dir: Path) -> list[Sample]:
|
|
175
|
+
"""Every worker's counters, from their files, this worker's written just now.
|
|
176
|
+
|
|
177
|
+
Only ever from the files, so that any worker gives the same answer. Adding this
|
|
178
|
+
worker's live counts to the others' older files would make totals go down when the
|
|
179
|
+
next scrape reached another worker, which Prometheus takes for a restart.
|
|
180
|
+
"""
|
|
181
|
+
with suppress(OSError): # On a full disk, its file is simply a little old.
|
|
182
|
+
flush(data_dir)
|
|
183
|
+
samples: list[Sample] = []
|
|
184
|
+
directory = data_dir / _DIRECTORY
|
|
185
|
+
if directory.is_dir():
|
|
186
|
+
for path in directory.glob("*.json"):
|
|
187
|
+
samples += [
|
|
188
|
+
(name, tuple(map(tuple, labels)), value)
|
|
189
|
+
for name, labels, value in json.loads(path.read_text("utf-8"))
|
|
190
|
+
]
|
|
191
|
+
return samples
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _family_of(name: str) -> str:
|
|
195
|
+
for suffix in ("_bucket", "_sum", "_count"):
|
|
196
|
+
if name.endswith(suffix) and name.removesuffix(suffix) == _LATENCY:
|
|
197
|
+
return _LATENCY
|
|
198
|
+
return name
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _bucket_order(labels: Labels) -> tuple[Labels, float]:
|
|
202
|
+
"""Sort buckets by their bound, which Prometheus requires."""
|
|
203
|
+
rest = tuple(item for item in labels if item[0] != "le")
|
|
204
|
+
bounds = [float(value) for name, value in labels if name == "le"]
|
|
205
|
+
return rest, bounds[0] if bounds else 0.0
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def render(samples: Iterable[Sample]) -> str:
|
|
209
|
+
"""Add up samples into the Prometheus text format."""
|
|
210
|
+
totals: dict[tuple[str, Labels], float] = defaultdict(float)
|
|
211
|
+
for name, labels, value in samples:
|
|
212
|
+
totals[name, labels] += value
|
|
213
|
+
by_family: dict[str, list[tuple[str, Labels, float]]] = defaultdict(list)
|
|
214
|
+
for (name, labels), value in totals.items():
|
|
215
|
+
by_family[_family_of(name)].append((name, labels, value))
|
|
216
|
+
lines = []
|
|
217
|
+
for family, members in sorted(by_family.items()):
|
|
218
|
+
kind, description = _FAMILIES[family]
|
|
219
|
+
lines += [f"# HELP {family} {description}", f"# TYPE {family} {kind}"]
|
|
220
|
+
members.sort(key=lambda member: (_bucket_order(member[1]), member[0]))
|
|
221
|
+
for name, labels, value in members:
|
|
222
|
+
text = ",".join(f'{key}="{item}"' for key, item in labels)
|
|
223
|
+
selector = f"{{{text}}}" if text else ""
|
|
224
|
+
# Not `g`, whose 6 digits would round a disk's free bytes.
|
|
225
|
+
lines.append(f"{name}{selector} {value:.17g}")
|
|
226
|
+
return "\n".join(lines) + "\n"
|
leblob/model.py
ADDED
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
"""Plain data shared by the pure core and the storage layers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from enum import StrEnum
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
NULL_VERSION_ID = "null"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass(frozen=True, slots=True)
|
|
17
|
+
class HttpRequest:
|
|
18
|
+
"""The parts of a request that routing and signatures depend on."""
|
|
19
|
+
|
|
20
|
+
method: str
|
|
21
|
+
raw_path: bytes
|
|
22
|
+
raw_query: bytes
|
|
23
|
+
query: Mapping[str, str]
|
|
24
|
+
headers: Mapping[str, str]
|
|
25
|
+
secure: bool = False # Whether it came over TLS.
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True, slots=True)
|
|
29
|
+
class ObjectRef:
|
|
30
|
+
"""A key in a bucket, whichever version of it."""
|
|
31
|
+
|
|
32
|
+
bucket: str
|
|
33
|
+
key: str
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True, slots=True)
|
|
37
|
+
class ByteSpan:
|
|
38
|
+
"""A range of an object's bytes, as its client knows them."""
|
|
39
|
+
|
|
40
|
+
start: int
|
|
41
|
+
length: int
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass(frozen=True, slots=True)
|
|
45
|
+
class UploadRef:
|
|
46
|
+
"""What identifies a multipart upload. All three parts must match."""
|
|
47
|
+
|
|
48
|
+
upload_id: str
|
|
49
|
+
bucket: str
|
|
50
|
+
key: str
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True, slots=True)
|
|
54
|
+
class Blob:
|
|
55
|
+
"""An immutable run of bytes on disk, named by a random ID."""
|
|
56
|
+
|
|
57
|
+
blob_id: str
|
|
58
|
+
size: int
|
|
59
|
+
md5: str
|
|
60
|
+
volume_ids: tuple[int, ...]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class ChecksumType(StrEnum):
|
|
64
|
+
"""How a multipart object's checksum relates to its parts."""
|
|
65
|
+
|
|
66
|
+
COMPOSITE = "COMPOSITE"
|
|
67
|
+
FULL_OBJECT = "FULL_OBJECT"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True, slots=True)
|
|
71
|
+
class KnownBlob:
|
|
72
|
+
"""What the metadata says about a blob file."""
|
|
73
|
+
|
|
74
|
+
size: int
|
|
75
|
+
queued_for_collection: bool
|
|
76
|
+
volume_ids: tuple[int, ...]
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True, slots=True)
|
|
80
|
+
class BlobFile:
|
|
81
|
+
"""A blob's file on one volume, as `fsck` reports it."""
|
|
82
|
+
|
|
83
|
+
volume_id: int
|
|
84
|
+
blob_id: str
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass(frozen=True, slots=True)
|
|
88
|
+
class VolumeRecord:
|
|
89
|
+
"""A volume as the metadata knows it. Its path is relative to the data directory."""
|
|
90
|
+
|
|
91
|
+
volume_id: int
|
|
92
|
+
identity: str
|
|
93
|
+
path: str
|
|
94
|
+
draining: bool = False
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(frozen=True, slots=True)
|
|
98
|
+
class RepairWork:
|
|
99
|
+
"""A blob queued for repair, and what the metadata wants of it right now."""
|
|
100
|
+
|
|
101
|
+
blob: Blob
|
|
102
|
+
copies: int
|
|
103
|
+
draining: frozenset[int]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@dataclass(frozen=True, slots=True)
|
|
107
|
+
class Checksum:
|
|
108
|
+
"""A checksum, in the form S3 headers carry it."""
|
|
109
|
+
|
|
110
|
+
algorithm: str
|
|
111
|
+
value: str
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def checksum_type(self) -> ChecksumType:
|
|
115
|
+
"""Tell them apart by the `-N` a composite value ends in, not valid base64."""
|
|
116
|
+
return ChecksumType.COMPOSITE if "-" in self.value else ChecksumType.FULL_OBJECT
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@dataclass(frozen=True, slots=True)
|
|
120
|
+
class ChecksumPlan:
|
|
121
|
+
"""The checksum a multipart upload declared its object will have."""
|
|
122
|
+
|
|
123
|
+
algorithm: str
|
|
124
|
+
checksum_type: ChecksumType
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
@dataclass(frozen=True, slots=True)
|
|
128
|
+
class ObjectRecord:
|
|
129
|
+
"""The current version of an object: its metadata and ordered blobs."""
|
|
130
|
+
|
|
131
|
+
key: str
|
|
132
|
+
size: int
|
|
133
|
+
etag: str
|
|
134
|
+
modified_at: int
|
|
135
|
+
headers: Mapping[str, str]
|
|
136
|
+
checksum: Checksum | None
|
|
137
|
+
blobs: tuple[Blob, ...]
|
|
138
|
+
version_id: str = NULL_VERSION_ID
|
|
139
|
+
tags: Mapping[str, str] = field(default_factory=dict)
|
|
140
|
+
public: bool = False
|
|
141
|
+
sse_check: str | None = None # Set if encrypted with a key the client keeps.
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
@dataclass(frozen=True, slots=True)
|
|
145
|
+
class VersionSummary:
|
|
146
|
+
"""The fields of an object version or delete marker that a listing returns."""
|
|
147
|
+
|
|
148
|
+
key: str
|
|
149
|
+
version_id: str
|
|
150
|
+
rank: int
|
|
151
|
+
is_delete_marker: bool
|
|
152
|
+
size: int
|
|
153
|
+
etag: str
|
|
154
|
+
modified_at: int
|
|
155
|
+
is_latest: bool = False
|
|
156
|
+
tags: Mapping[str, str] = field(default_factory=dict)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class Versioning(StrEnum):
|
|
160
|
+
"""A bucket's versioning state. A bucket that never had any has none."""
|
|
161
|
+
|
|
162
|
+
ENABLED = "Enabled"
|
|
163
|
+
SUSPENDED = "Suspended"
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@dataclass(frozen=True, slots=True)
|
|
167
|
+
class DeleteTarget:
|
|
168
|
+
"""What a bulk delete names: a key, and perhaps one version of it."""
|
|
169
|
+
|
|
170
|
+
key: str
|
|
171
|
+
version_id: str | None = None
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@dataclass(frozen=True, slots=True)
|
|
175
|
+
class DeleteOutcome:
|
|
176
|
+
"""What a delete did. Unversioned buckets have no version to report."""
|
|
177
|
+
|
|
178
|
+
key: str
|
|
179
|
+
version_id: str | None = None
|
|
180
|
+
is_delete_marker: bool = False
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@dataclass(frozen=True, slots=True)
|
|
184
|
+
class ObjectSummary:
|
|
185
|
+
"""The fields of an object that a listing returns."""
|
|
186
|
+
|
|
187
|
+
key: str
|
|
188
|
+
size: int
|
|
189
|
+
etag: str
|
|
190
|
+
modified_at: int
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@dataclass(frozen=True, slots=True)
|
|
194
|
+
class BucketRecord:
|
|
195
|
+
name: str
|
|
196
|
+
created_at: int
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@dataclass(frozen=True, slots=True)
|
|
200
|
+
class Part:
|
|
201
|
+
"""One uploaded part of a multipart upload."""
|
|
202
|
+
|
|
203
|
+
number: int
|
|
204
|
+
blob: Blob
|
|
205
|
+
checksum: Checksum | None
|
|
206
|
+
modified_at: int
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
@dataclass(frozen=True, slots=True)
|
|
210
|
+
class Upload:
|
|
211
|
+
"""A multipart upload in progress."""
|
|
212
|
+
|
|
213
|
+
upload_id: str
|
|
214
|
+
bucket: str
|
|
215
|
+
key: str
|
|
216
|
+
headers: Mapping[str, str]
|
|
217
|
+
created_at: int
|
|
218
|
+
checksum: ChecksumPlan | None
|
|
219
|
+
tags: Mapping[str, str] = field(default_factory=dict)
|
|
220
|
+
public: bool = False
|
|
221
|
+
sse_check: str | None = None
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@dataclass(frozen=True, slots=True)
|
|
225
|
+
class UploadExtras:
|
|
226
|
+
"""What an upload promises the object it will become, beyond its headers."""
|
|
227
|
+
|
|
228
|
+
tags: Mapping[str, str] = field(default_factory=dict)
|
|
229
|
+
public: bool = False
|
|
230
|
+
sse_check: str | None = None
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
@dataclass(frozen=True, slots=True)
|
|
234
|
+
class RequestedPart:
|
|
235
|
+
"""A part a client asks to assemble, and what it believes about that part."""
|
|
236
|
+
|
|
237
|
+
number: int
|
|
238
|
+
etag: str
|
|
239
|
+
checksums: tuple[Checksum, ...] = ()
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
@dataclass(frozen=True, slots=True)
|
|
243
|
+
class CompletionRequest:
|
|
244
|
+
"""A CompleteMultipartUpload request: the parts, and claims about the object."""
|
|
245
|
+
|
|
246
|
+
parts: tuple[RequestedPart, ...]
|
|
247
|
+
checksum: Checksum | None = None
|
|
248
|
+
checksum_type: str | None = None
|
|
249
|
+
object_size: int | None = None
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
@dataclass(frozen=True, slots=True)
|
|
253
|
+
class CompletedUpload:
|
|
254
|
+
"""The result of completing an upload, which a retry must see unchanged."""
|
|
255
|
+
|
|
256
|
+
etag: str
|
|
257
|
+
checksum: Checksum | None
|
|
258
|
+
version_id: str | None = None
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
@dataclass(frozen=True, slots=True)
|
|
262
|
+
class BlobSlice:
|
|
263
|
+
"""A byte range inside one blob."""
|
|
264
|
+
|
|
265
|
+
blob: Blob
|
|
266
|
+
offset: int
|
|
267
|
+
length: int
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
@dataclass(frozen=True, slots=True)
|
|
271
|
+
class RuleScope:
|
|
272
|
+
"""What a lifecycle rule applies to, and which form the client wrote it in."""
|
|
273
|
+
|
|
274
|
+
prefix: str | None = None
|
|
275
|
+
legacy_prefix: bool = False # Written as <Prefix>, rather than inside <Filter>.
|
|
276
|
+
larger_than: int | None = None
|
|
277
|
+
smaller_than: int | None = None
|
|
278
|
+
tags: tuple[tuple[str, str], ...] = ()
|
|
279
|
+
|
|
280
|
+
def looks_inside_objects(self) -> bool:
|
|
281
|
+
"""Whether it needs more than a key, as an upload has so far."""
|
|
282
|
+
return (
|
|
283
|
+
self.larger_than is not None
|
|
284
|
+
or self.smaller_than is not None
|
|
285
|
+
or bool(self.tags)
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
@dataclass(frozen=True, slots=True)
|
|
290
|
+
class RuleActions:
|
|
291
|
+
"""What a lifecycle rule does. The default, doing nothing, isn't a valid rule."""
|
|
292
|
+
|
|
293
|
+
expire_days: int | None = None
|
|
294
|
+
expire_at: int | None = None # Milliseconds since the epoch.
|
|
295
|
+
expire_lone_markers: bool = False
|
|
296
|
+
noncurrent_days: int | None = None
|
|
297
|
+
keep_noncurrent: int = 0
|
|
298
|
+
abort_upload_days: int | None = None
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
@dataclass(frozen=True, slots=True)
|
|
302
|
+
class LifecycleRule:
|
|
303
|
+
rule_id: str
|
|
304
|
+
enabled: bool
|
|
305
|
+
scope: RuleScope
|
|
306
|
+
actions: RuleActions
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
@dataclass(frozen=True, slots=True)
|
|
310
|
+
class KeyVerdict:
|
|
311
|
+
"""What lifecycle rules say to do with one key."""
|
|
312
|
+
|
|
313
|
+
expire_current: bool = False
|
|
314
|
+
erase: tuple[str, ...] = ()
|
|
315
|
+
|
|
316
|
+
@property
|
|
317
|
+
def removals(self) -> int:
|
|
318
|
+
return int(self.expire_current) + len(self.erase)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
@dataclass(frozen=True, slots=True)
|
|
322
|
+
class LifecycleReport:
|
|
323
|
+
"""What one batch of lifecycle work removed."""
|
|
324
|
+
|
|
325
|
+
expired: int = 0
|
|
326
|
+
erased: int = 0
|
|
327
|
+
aborted: int = 0
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
@dataclass(frozen=True, slots=True)
|
|
331
|
+
class CorsRule:
|
|
332
|
+
"""Which browser pages may send which requests to a bucket."""
|
|
333
|
+
|
|
334
|
+
origins: tuple[str, ...]
|
|
335
|
+
methods: tuple[str, ...]
|
|
336
|
+
allowed_headers: tuple[str, ...] = ()
|
|
337
|
+
expose: tuple[str, ...] = ()
|
|
338
|
+
rule_id: str | None = None
|
|
339
|
+
max_age: int | None = None
|
leblob/s3/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""The S3 protocol as pure functions: no sockets, files or databases here."""
|
leblob/s3/access.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""What an access key may do: a pure decision, taken once per request."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from enum import IntEnum
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
from leblob.s3 import errors
|
|
10
|
+
from leblob.s3.errors import S3Error
|
|
11
|
+
from leblob.s3.routing import BucketRoute, ObjectRoute, Operation
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from collections.abc import Mapping
|
|
15
|
+
|
|
16
|
+
from leblob.s3.routing import Route
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Level(IntEnum):
|
|
20
|
+
"""A right on one bucket. Each level includes the ones below it."""
|
|
21
|
+
|
|
22
|
+
READ = 1
|
|
23
|
+
WRITE = 2
|
|
24
|
+
ADMIN = 3
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True, slots=True)
|
|
28
|
+
class Scope:
|
|
29
|
+
"""The buckets a scoped key was granted, or None for a key with full access."""
|
|
30
|
+
|
|
31
|
+
buckets: Mapping[str, Level] | None = None
|
|
32
|
+
|
|
33
|
+
def allows(self, bucket: str, level: Level) -> bool:
|
|
34
|
+
if self.buckets is None:
|
|
35
|
+
return True
|
|
36
|
+
granted = self.buckets.get(bucket)
|
|
37
|
+
return granted is not None and granted >= level
|
|
38
|
+
|
|
39
|
+
def sees(self, bucket: str) -> bool:
|
|
40
|
+
return self.buckets is None or bucket in self.buckets
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
FULL = Scope()
|
|
44
|
+
ANONYMOUS = Scope(buckets={}) # Sees nothing, unless an ACL says otherwise.
|
|
45
|
+
ANONYMOUS_BUCKET_OPERATIONS = frozenset({
|
|
46
|
+
Operation.HEAD_BUCKET,
|
|
47
|
+
Operation.LIST_OBJECTS,
|
|
48
|
+
Operation.LIST_OBJECTS_V2,
|
|
49
|
+
Operation.LIST_OBJECT_VERSIONS,
|
|
50
|
+
})
|
|
51
|
+
ANONYMOUS_OBJECT_OPERATIONS = frozenset({Operation.GET_OBJECT, Operation.HEAD_OBJECT})
|
|
52
|
+
|
|
53
|
+
# An operation left out of this table is for full-access keys only, so one added
|
|
54
|
+
# to the router later is closed to scoped keys until someone decides otherwise.
|
|
55
|
+
_REQUIRED = {
|
|
56
|
+
Operation.HEAD_BUCKET: Level.READ,
|
|
57
|
+
Operation.GET_BUCKET_LOCATION: Level.READ,
|
|
58
|
+
Operation.GET_BUCKET_VERSIONING: Level.READ,
|
|
59
|
+
Operation.GET_BUCKET_LIFECYCLE: Level.READ,
|
|
60
|
+
Operation.GET_BUCKET_CORS: Level.READ,
|
|
61
|
+
Operation.GET_BUCKET_ACL: Level.READ,
|
|
62
|
+
Operation.GET_OBJECT_ACL: Level.READ,
|
|
63
|
+
Operation.LIST_OBJECTS: Level.READ,
|
|
64
|
+
Operation.LIST_OBJECTS_V2: Level.READ,
|
|
65
|
+
Operation.LIST_OBJECT_VERSIONS: Level.READ,
|
|
66
|
+
Operation.GET_OBJECT: Level.READ,
|
|
67
|
+
Operation.HEAD_OBJECT: Level.READ,
|
|
68
|
+
Operation.GET_OBJECT_TAGGING: Level.READ,
|
|
69
|
+
Operation.PUT_OBJECT: Level.WRITE,
|
|
70
|
+
Operation.COPY_OBJECT: Level.WRITE,
|
|
71
|
+
Operation.DELETE_OBJECT: Level.WRITE,
|
|
72
|
+
Operation.DELETE_OBJECTS: Level.WRITE,
|
|
73
|
+
Operation.PUT_OBJECT_ACL: Level.WRITE, # And ADMIN to make it public.
|
|
74
|
+
Operation.PUT_OBJECT_TAGGING: Level.WRITE, # And ADMIN to tag an older version.
|
|
75
|
+
Operation.DELETE_OBJECT_TAGGING: Level.WRITE,
|
|
76
|
+
Operation.CREATE_MULTIPART_UPLOAD: Level.WRITE,
|
|
77
|
+
Operation.UPLOAD_PART: Level.WRITE,
|
|
78
|
+
Operation.UPLOAD_PART_COPY: Level.WRITE, # And READ on the source, once it's known.
|
|
79
|
+
Operation.COMPLETE_MULTIPART_UPLOAD: Level.WRITE,
|
|
80
|
+
Operation.ABORT_MULTIPART_UPLOAD: Level.WRITE,
|
|
81
|
+
Operation.LIST_PARTS: Level.WRITE,
|
|
82
|
+
Operation.LIST_MULTIPART_UPLOADS: Level.WRITE,
|
|
83
|
+
Operation.DELETE_BUCKET: Level.ADMIN,
|
|
84
|
+
# Erasing history takes more than writing: a stolen write key can't destroy what
|
|
85
|
+
# versioning is there to keep.
|
|
86
|
+
Operation.DELETE_OBJECT_VERSION: Level.ADMIN,
|
|
87
|
+
Operation.PUT_BUCKET_VERSIONING: Level.ADMIN,
|
|
88
|
+
# A rule can erase history as surely as a request can.
|
|
89
|
+
Operation.PUT_BUCKET_LIFECYCLE: Level.ADMIN,
|
|
90
|
+
Operation.DELETE_BUCKET_LIFECYCLE: Level.ADMIN,
|
|
91
|
+
Operation.PUT_BUCKET_CORS: Level.ADMIN,
|
|
92
|
+
# Publishing takes more than writing: a stolen write key can't expose a bucket.
|
|
93
|
+
Operation.PUT_BUCKET_ACL: Level.ADMIN,
|
|
94
|
+
Operation.DELETE_BUCKET_CORS: Level.ADMIN,
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def require(scope: Scope, bucket: str, level: Level) -> None:
|
|
99
|
+
if not scope.allows(bucket, level):
|
|
100
|
+
raise S3Error(errors.ACCESS_DENIED)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def authorize(scope: Scope, route: Route) -> None:
|
|
104
|
+
"""Refuse before any bucket is looked up, so a refusal says nothing about it."""
|
|
105
|
+
if scope.buckets is None or not isinstance(route, BucketRoute | ObjectRoute):
|
|
106
|
+
return # Listing buckets is open to all: it shows what a key can see.
|
|
107
|
+
level = _REQUIRED.get(route.operation)
|
|
108
|
+
if level is None:
|
|
109
|
+
raise S3Error(errors.ACCESS_DENIED)
|
|
110
|
+
require(scope, route.bucket, level)
|