cctv-stack 0.3.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.
- cctv/__init__.py +19 -0
- cctv/atomic.py +184 -0
- cctv/cleanup.py +1014 -0
- cctv/clip.py +278 -0
- cctv/config.py +742 -0
- cctv/config_cli.py +217 -0
- cctv/db.py +1478 -0
- cctv/dispatch.py +270 -0
- cctv/ffprobe.py +246 -0
- cctv/frigate.py +411 -0
- cctv/health.py +413 -0
- cctv/logging.py +157 -0
- cctv/materialise.py +488 -0
- cctv/migrations/0001_init.sql +199 -0
- cctv/migrations/0002_finding_kinds.sql +35 -0
- cctv/mqtt.py +171 -0
- cctv/paths.py +290 -0
- cctv/reviews.py +279 -0
- cctv/storage.py +751 -0
- cctv/sweep.py +254 -0
- cctv/timeutil.py +154 -0
- cctv/uploader.py +1417 -0
- cctv/worker.py +385 -0
- cctv_stack-0.3.0.dist-info/METADATA +410 -0
- cctv_stack-0.3.0.dist-info/RECORD +28 -0
- cctv_stack-0.3.0.dist-info/WHEEL +4 -0
- cctv_stack-0.3.0.dist-info/entry_points.txt +5 -0
- cctv_stack-0.3.0.dist-info/licenses/LICENSE +201 -0
cctv/storage.py
ADDED
|
@@ -0,0 +1,751 @@
|
|
|
1
|
+
"""The storage adapter — the only module that knows what a "cloud" is.
|
|
2
|
+
|
|
3
|
+
[ADR-0005](../../../docs/adr/0005-b2-behind-storage-adapter.md) puts the provider behind a
|
|
4
|
+
deliberately tiny interface. This module is that interface, plus the one implementation we
|
|
5
|
+
ship (S3-compatible, which is how Backblaze B2 is reached and how RustFS stands in for it in
|
|
6
|
+
CI — [ADR-0019](../../../docs/adr/0019-rustfs-as-s3-test-double.md)).
|
|
7
|
+
|
|
8
|
+
Three decisions here are worth stating, because each of them is a place where a plausible
|
|
9
|
+
simplification causes data loss:
|
|
10
|
+
|
|
11
|
+
1. **An existing object with different content is never overwritten.** Silently replacing an
|
|
12
|
+
object is how the only good copy is lost. The adapter raises, the clip stays `PENDING`,
|
|
13
|
+
and a human decides ([INV-7](../../../docs/architecture/invariants.md)).
|
|
14
|
+
2. **A verification we did not perform is never claimed.** `PutResult.verified_by` says
|
|
15
|
+
exactly what was checked — `sha256`, `size`, or `none` — and `none` makes the uploader
|
|
16
|
+
degrade rather than declare an unverified backup a backup (lld/04 §3 step 5).
|
|
17
|
+
3. **`delete_object` reports whether the object is *gone*, not whether a request
|
|
18
|
+
succeeded.** B2 keeps versions, so a plain delete can leave a hidden object still
|
|
19
|
+
billing for storage. Claiming success would be a lie that costs money.
|
|
20
|
+
|
|
21
|
+
`boto3` is imported lazily, for the same reason `paho` is in [`cctv.mqtt`][cctv.mqtt]: a
|
|
22
|
+
deployment with no cloud backup should not have to install an SDK it will never call, and a
|
|
23
|
+
missing library must produce an actionable message rather than an `ImportError` at start-up.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import base64
|
|
29
|
+
import binascii
|
|
30
|
+
import time
|
|
31
|
+
from collections.abc import Iterator, Mapping
|
|
32
|
+
from dataclasses import dataclass, field
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from typing import Any, ClassVar, Protocol, runtime_checkable
|
|
35
|
+
|
|
36
|
+
__all__ = [
|
|
37
|
+
"CONTENT_TYPE_MP4",
|
|
38
|
+
"DEFAULT_MULTIPART_THRESHOLD_BYTES",
|
|
39
|
+
"AdapterHealth",
|
|
40
|
+
"AuthStorageError",
|
|
41
|
+
"BucketNotFoundError",
|
|
42
|
+
"ChecksumMismatchError",
|
|
43
|
+
"DeleteResult",
|
|
44
|
+
"InvalidRequestError",
|
|
45
|
+
"ObjectConflictError",
|
|
46
|
+
"ObjectInfo",
|
|
47
|
+
"PutResult",
|
|
48
|
+
"QuotaExceededError",
|
|
49
|
+
"S3StorageAdapter",
|
|
50
|
+
"StorageAdapter",
|
|
51
|
+
"StorageBackendMissingError",
|
|
52
|
+
"StorageError",
|
|
53
|
+
"TransientStorageError",
|
|
54
|
+
"classify_client_error",
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
CONTENT_TYPE_MP4 = "video/mp4"
|
|
58
|
+
|
|
59
|
+
#: At or above this size the adapter switches to a multipart upload. Below it a single PUT
|
|
60
|
+
#: is used, which is the only shape that gives us a provider checksum comparable to a plain
|
|
61
|
+
#: SHA-256 of the file (cloud-object-layout §4.2).
|
|
62
|
+
DEFAULT_MULTIPART_THRESHOLD_BYTES = 100 * 1024 * 1024
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ---------------------------------------------------------------------------------------
|
|
66
|
+
# Errors — classification drives retry, backoff and alerting (lld/04 §4)
|
|
67
|
+
# ---------------------------------------------------------------------------------------
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class StorageError(RuntimeError):
|
|
71
|
+
"""A storage operation failed, classified.
|
|
72
|
+
|
|
73
|
+
`error_class` is one of the six the database's `last_error_class` CHECK accepts
|
|
74
|
+
(contract §2.3), and `retryable` is the decision that actually matters to the caller.
|
|
75
|
+
Keeping both on the exception means the uploader never has to re-derive "is a 403 worth
|
|
76
|
+
retrying?" from a message string.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
error_class: ClassVar[str] = "unknown"
|
|
80
|
+
retryable: ClassVar[bool] = False
|
|
81
|
+
|
|
82
|
+
def __init__(self, message: str, *, http_status: int | None = None) -> None:
|
|
83
|
+
super().__init__(message)
|
|
84
|
+
self.http_status = http_status
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class TransientStorageError(StorageError):
|
|
88
|
+
"""A `5xx`, a reset connection, a DNS failure or a timeout. Retried indefinitely."""
|
|
89
|
+
|
|
90
|
+
error_class: ClassVar[str] = "transient"
|
|
91
|
+
retryable: ClassVar[bool] = True
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
class AuthStorageError(StorageError):
|
|
95
|
+
"""Credentials were rejected. Never retried: retrying cannot succeed.
|
|
96
|
+
|
|
97
|
+
Retrying a `403` produces thousands of failed requests that bury the real signal and
|
|
98
|
+
make a broken system look busy. The fix is a credential rotation, and the runbook is
|
|
99
|
+
[`rotate-credentials.md`](../../../docs/runbooks/rotate-credentials.md).
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
error_class: ClassVar[str] = "auth"
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class BucketNotFoundError(StorageError):
|
|
106
|
+
"""The bucket does not exist. Never retried; the message names the expected bucket."""
|
|
107
|
+
|
|
108
|
+
error_class: ClassVar[str] = "not_found"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
class QuotaExceededError(StorageError):
|
|
112
|
+
"""A provider cap, a payment problem, or a full account. Retried with a longer ceiling.
|
|
113
|
+
|
|
114
|
+
Retryable because caps are sometimes transient (a daily counter that resets), but at a
|
|
115
|
+
much longer interval — this one needs a person either way.
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
error_class: ClassVar[str] = "quota"
|
|
119
|
+
retryable: ClassVar[bool] = True
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class ChecksumMismatchError(StorageError):
|
|
123
|
+
"""The provider's checksum for the stored object does not match the local file.
|
|
124
|
+
|
|
125
|
+
Never retried. Re-uploading identical bytes that produce a different stored checksum is
|
|
126
|
+
evidence of corruption, not of a transient fault, and retrying it just burns time.
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
error_class: ClassVar[str] = "checksum"
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class InvalidRequestError(StorageError):
|
|
133
|
+
"""A `400`. Our bug or a configuration error, so retrying is pointless.
|
|
134
|
+
|
|
135
|
+
Carries the database's `unknown` class rather than a class of its own because the
|
|
136
|
+
`last_error_class` CHECK accepts six values and adding a seventh would mean a table
|
|
137
|
+
rebuild for a label. The distinction the operator needs — *no retry* — is carried by
|
|
138
|
+
`retryable`, and the message says what the provider objected to.
|
|
139
|
+
"""
|
|
140
|
+
|
|
141
|
+
error_class: ClassVar[str] = "unknown"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
class ObjectConflictError(StorageError):
|
|
145
|
+
"""An object already exists at this key with content we cannot prove identical.
|
|
146
|
+
|
|
147
|
+
The most dangerous possible state: overwriting could destroy the only good copy, and
|
|
148
|
+
assuming identity could mark a corrupt object as a verified backup. So neither happens —
|
|
149
|
+
the clip stays `PENDING`, the health check degrades, and a human resolves it.
|
|
150
|
+
"""
|
|
151
|
+
|
|
152
|
+
error_class: ClassVar[str] = "unknown"
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class StorageBackendMissingError(StorageError):
|
|
156
|
+
"""`boto3` is not installed. A configuration error, not a runtime one."""
|
|
157
|
+
|
|
158
|
+
error_class: ClassVar[str] = "unknown"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# ---------------------------------------------------------------------------------------
|
|
162
|
+
# Results
|
|
163
|
+
# ---------------------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
@dataclass(frozen=True, slots=True)
|
|
167
|
+
class PutResult:
|
|
168
|
+
"""What actually happened during an upload.
|
|
169
|
+
|
|
170
|
+
`verified_by` is the honest record of the verification performed, and it is deliberately
|
|
171
|
+
a string rather than a bool: `"sha256"` and `"size"` are not interchangeable, and the
|
|
172
|
+
uploader reports a `"none"` as `DEGRADED` because an unverified backup is not a backup.
|
|
173
|
+
"""
|
|
174
|
+
|
|
175
|
+
key: str
|
|
176
|
+
size: int
|
|
177
|
+
verified_by: str # "sha256" | "size" | "none"
|
|
178
|
+
etag: str | None = None
|
|
179
|
+
#: True when the object already existed with identical content, so no bytes were sent.
|
|
180
|
+
#: This is what makes a re-run after a crash a no-op rather than a second upload.
|
|
181
|
+
already_present: bool = False
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@dataclass(frozen=True, slots=True)
|
|
185
|
+
class ObjectInfo:
|
|
186
|
+
key: str
|
|
187
|
+
size: int
|
|
188
|
+
etag: str | None = None
|
|
189
|
+
metadata: Mapping[str, str] = field(default_factory=dict)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@dataclass(frozen=True, slots=True)
|
|
193
|
+
class DeleteResult:
|
|
194
|
+
"""Whether the object is *actually gone* — not whether a request was accepted.
|
|
195
|
+
|
|
196
|
+
`deleted=False` on a `200` response is a real and expected outcome on B2 when the bucket
|
|
197
|
+
keeps versions: the delete wrote a hide marker and the bytes are still there, still
|
|
198
|
+
billed. Callers must treat `deleted=False` as a failure to delete.
|
|
199
|
+
"""
|
|
200
|
+
|
|
201
|
+
key: str
|
|
202
|
+
deleted: bool
|
|
203
|
+
detail: str | None = None
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@dataclass(frozen=True, slots=True)
|
|
207
|
+
class AdapterHealth:
|
|
208
|
+
"""`healthcheck()`'s answer, split so an operator gets the right fix.
|
|
209
|
+
|
|
210
|
+
Unreachable, credentials rejected and bucket missing have three different remedies, and
|
|
211
|
+
collapsing them into one boolean is how "storage is down" sends someone to the wrong
|
|
212
|
+
runbook.
|
|
213
|
+
"""
|
|
214
|
+
|
|
215
|
+
reachable: bool
|
|
216
|
+
credentials_ok: bool
|
|
217
|
+
bucket_present: bool
|
|
218
|
+
detail: str
|
|
219
|
+
latency_ms: int | None = None
|
|
220
|
+
error: StorageError | None = None
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
# ---------------------------------------------------------------------------------------
|
|
224
|
+
# The interface
|
|
225
|
+
# ---------------------------------------------------------------------------------------
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
@runtime_checkable
|
|
229
|
+
class StorageAdapter(Protocol):
|
|
230
|
+
"""The provider-specific surface (cloud-object-layout §6).
|
|
231
|
+
|
|
232
|
+
Anything larger than this would leak the provider into the uploader's logic and defeat
|
|
233
|
+
[ADR-0005](../../../docs/adr/0005-b2-behind-storage-adapter.md).
|
|
234
|
+
"""
|
|
235
|
+
|
|
236
|
+
def put_object(
|
|
237
|
+
self,
|
|
238
|
+
*,
|
|
239
|
+
key: str,
|
|
240
|
+
path: Path,
|
|
241
|
+
metadata: Mapping[str, str],
|
|
242
|
+
checksum_sha256: str,
|
|
243
|
+
) -> PutResult: ...
|
|
244
|
+
|
|
245
|
+
def head_object(self, *, key: str) -> ObjectInfo | None: ...
|
|
246
|
+
|
|
247
|
+
def delete_object(self, *, key: str) -> DeleteResult: ...
|
|
248
|
+
|
|
249
|
+
def list_objects(self, *, prefix: str, max_keys: int = 1000) -> Iterator[ObjectInfo]: ...
|
|
250
|
+
|
|
251
|
+
def presign_get(self, *, key: str, ttl_s: int) -> str: ...
|
|
252
|
+
|
|
253
|
+
def healthcheck(self) -> AdapterHealth: ...
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
# ---------------------------------------------------------------------------------------
|
|
257
|
+
# Classification — the code that decides retry, backoff and alarm
|
|
258
|
+
# ---------------------------------------------------------------------------------------
|
|
259
|
+
|
|
260
|
+
#: Provider error codes, grouped by what the operator should do about them. Matched against
|
|
261
|
+
#: `ClientError.response["Error"]["Code"]` and, as a fallback, the HTTP status. Both,
|
|
262
|
+
#: because S3-compatible providers are inconsistent about which they populate.
|
|
263
|
+
_AUTH_CODES = frozenset(
|
|
264
|
+
{
|
|
265
|
+
"AccessDenied",
|
|
266
|
+
"AccountProblem",
|
|
267
|
+
"ExpiredToken",
|
|
268
|
+
"InvalidAccessKeyId",
|
|
269
|
+
"InvalidToken",
|
|
270
|
+
"SignatureDoesNotMatch",
|
|
271
|
+
"UnauthorizedAccess",
|
|
272
|
+
}
|
|
273
|
+
)
|
|
274
|
+
_NOT_FOUND_CODES = frozenset({"NoSuchBucket", "NotFound"})
|
|
275
|
+
_QUOTA_CODES = frozenset(
|
|
276
|
+
{"InsufficientStorage", "QuotaExceeded", "ServiceQuotaExceeded", "StorageFull"}
|
|
277
|
+
)
|
|
278
|
+
_THROTTLE_CODES = frozenset(
|
|
279
|
+
{
|
|
280
|
+
"ProvisionedThroughputExceededException",
|
|
281
|
+
"RequestLimitExceeded",
|
|
282
|
+
"SlowDown",
|
|
283
|
+
"Throttling",
|
|
284
|
+
"ThrottlingException",
|
|
285
|
+
"TooManyRequests",
|
|
286
|
+
}
|
|
287
|
+
)
|
|
288
|
+
_SERVER_CODES = frozenset({"InternalError", "RequestTimeout", "ServiceUnavailable", "SlowDown"})
|
|
289
|
+
_INVALID_CODES = frozenset(
|
|
290
|
+
{
|
|
291
|
+
"EntityTooLarge",
|
|
292
|
+
"InvalidArgument",
|
|
293
|
+
"InvalidBucketName",
|
|
294
|
+
"InvalidRequest",
|
|
295
|
+
"KeyTooLong",
|
|
296
|
+
}
|
|
297
|
+
)
|
|
298
|
+
_CHECKSUM_CODES = frozenset({"BadDigest", "InvalidDigest"})
|
|
299
|
+
|
|
300
|
+
_AUTH_STATUSES = frozenset({401, 403})
|
|
301
|
+
_NOT_FOUND_STATUSES = frozenset({404})
|
|
302
|
+
_QUOTA_STATUSES = frozenset({507})
|
|
303
|
+
_THROTTLE_STATUSES = frozenset({429})
|
|
304
|
+
_TRANSIENT_STATUSES = frozenset({500, 502, 503, 504})
|
|
305
|
+
_INVALID_STATUSES = frozenset({400})
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def classify_client_error(exc: BaseException, *, key: str | None = None) -> StorageError:
|
|
309
|
+
"""Turn a provider exception into one of our classified errors.
|
|
310
|
+
|
|
311
|
+
The classification is the whole point: it decides whether we retry (and how long) or
|
|
312
|
+
stop and shout. `unknown` defaults to **retryable-transient**, because the costs are
|
|
313
|
+
asymmetric — retrying something permanent costs a bounded number of attempts and some
|
|
314
|
+
noise, while not retrying something transient costs a clip.
|
|
315
|
+
"""
|
|
316
|
+
response = getattr(exc, "response", None) or {}
|
|
317
|
+
error = response.get("Error") or {}
|
|
318
|
+
status = (response.get("ResponseMetadata") or {}).get("HTTPStatusCode")
|
|
319
|
+
code = str(error.get("Code") or "")
|
|
320
|
+
message = str(error.get("Message") or exc)
|
|
321
|
+
where = f" for {key!r}" if key else ""
|
|
322
|
+
|
|
323
|
+
if code in _CHECKSUM_CODES:
|
|
324
|
+
return ChecksumMismatchError(
|
|
325
|
+
f"The provider rejected our checksum{where}: {message}. "
|
|
326
|
+
f"The local file is preserved and the clip is not retried.",
|
|
327
|
+
http_status=status,
|
|
328
|
+
)
|
|
329
|
+
if code in _AUTH_CODES:
|
|
330
|
+
return AuthStorageError(
|
|
331
|
+
f"Storage rejected the credentials{where}: {message}. "
|
|
332
|
+
f"Rotate them (runbooks/rotate-credentials.md); retrying will not help.",
|
|
333
|
+
http_status=status,
|
|
334
|
+
)
|
|
335
|
+
if code in _NOT_FOUND_CODES:
|
|
336
|
+
return BucketNotFoundError(
|
|
337
|
+
f"Storage could not find the bucket or object{where}: {message}.",
|
|
338
|
+
http_status=status,
|
|
339
|
+
)
|
|
340
|
+
if code in _QUOTA_CODES:
|
|
341
|
+
return QuotaExceededError(
|
|
342
|
+
f"Storage is over quota or out of space{where}: {message}. This needs a person.",
|
|
343
|
+
http_status=status,
|
|
344
|
+
)
|
|
345
|
+
if code in _THROTTLE_CODES or code in _SERVER_CODES:
|
|
346
|
+
return TransientStorageError(
|
|
347
|
+
f"Storage is temporarily unavailable{where}: {message}.",
|
|
348
|
+
http_status=status,
|
|
349
|
+
)
|
|
350
|
+
if code in _INVALID_CODES:
|
|
351
|
+
return InvalidRequestError(
|
|
352
|
+
f"Storage rejected the request{where}: {message}. "
|
|
353
|
+
f"This is our bug or a configuration error, so it is not retried.",
|
|
354
|
+
http_status=status,
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
# No code we recognise, so fall back to the status — which is at least unambiguous.
|
|
358
|
+
if status in _AUTH_STATUSES:
|
|
359
|
+
return AuthStorageError(
|
|
360
|
+
f"Storage rejected the credentials{where}: {message}. "
|
|
361
|
+
f"Rotate them (runbooks/rotate-credentials.md); retrying will not help.",
|
|
362
|
+
http_status=status,
|
|
363
|
+
)
|
|
364
|
+
if status in _NOT_FOUND_STATUSES:
|
|
365
|
+
return BucketNotFoundError(
|
|
366
|
+
f"Storage could not find the bucket or object{where}: {message}.",
|
|
367
|
+
http_status=status,
|
|
368
|
+
)
|
|
369
|
+
if status in _QUOTA_STATUSES:
|
|
370
|
+
return QuotaExceededError(
|
|
371
|
+
f"Storage is over quota or out of space{where}: {message}. This needs a person.",
|
|
372
|
+
http_status=status,
|
|
373
|
+
)
|
|
374
|
+
if status in _THROTTLE_STATUSES or status in _TRANSIENT_STATUSES:
|
|
375
|
+
return TransientStorageError(
|
|
376
|
+
f"Storage is temporarily unavailable{where}: {message}.",
|
|
377
|
+
http_status=status,
|
|
378
|
+
)
|
|
379
|
+
if status in _INVALID_STATUSES:
|
|
380
|
+
return InvalidRequestError(
|
|
381
|
+
f"Storage rejected the request{where}: {message}. "
|
|
382
|
+
f"This is our bug or a configuration error, so it is not retried.",
|
|
383
|
+
http_status=status,
|
|
384
|
+
)
|
|
385
|
+
return TransientStorageError(
|
|
386
|
+
f"Unexpected storage failure{where}: {type(exc).__name__}: {message}. "
|
|
387
|
+
f"Treated as transient, conservatively — a bounded number of retries is cheaper "
|
|
388
|
+
f"than a lost clip.",
|
|
389
|
+
http_status=status,
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
# ---------------------------------------------------------------------------------------
|
|
394
|
+
# The S3-compatible implementation
|
|
395
|
+
# ---------------------------------------------------------------------------------------
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
class S3StorageAdapter:
|
|
399
|
+
"""Backblaze B2 (and MinIO, and anything else S3-compatible) behind `StorageAdapter`.
|
|
400
|
+
|
|
401
|
+
The client is constructible from an injected object so tests can drive every branch
|
|
402
|
+
without a `boto3` install and without a network — the classification, the conflict rules
|
|
403
|
+
and the checksum comparison are exactly the code that must not be tested only by
|
|
404
|
+
inspection.
|
|
405
|
+
"""
|
|
406
|
+
|
|
407
|
+
def __init__(
|
|
408
|
+
self,
|
|
409
|
+
*,
|
|
410
|
+
bucket: str,
|
|
411
|
+
access_key_id: str,
|
|
412
|
+
secret_access_key: str,
|
|
413
|
+
endpoint_url: str | None = None,
|
|
414
|
+
region: str = "us-west-004",
|
|
415
|
+
multipart_threshold_bytes: int = DEFAULT_MULTIPART_THRESHOLD_BYTES,
|
|
416
|
+
client: Any | None = None,
|
|
417
|
+
) -> None:
|
|
418
|
+
if not bucket:
|
|
419
|
+
raise ValueError("A bucket name is required.")
|
|
420
|
+
self._bucket = bucket
|
|
421
|
+
self._endpoint_url = endpoint_url
|
|
422
|
+
self._region = region
|
|
423
|
+
self._access_key_id = access_key_id
|
|
424
|
+
self._secret_access_key = secret_access_key
|
|
425
|
+
self._multipart_threshold = multipart_threshold_bytes
|
|
426
|
+
self._client = client
|
|
427
|
+
|
|
428
|
+
# -- client ------------------------------------------------------------------------
|
|
429
|
+
|
|
430
|
+
@property
|
|
431
|
+
def client(self) -> Any:
|
|
432
|
+
"""The `boto3` client, created on first use.
|
|
433
|
+
|
|
434
|
+
`boto3` is imported here rather than at module import so that a deployment without
|
|
435
|
+
cloud backup never needs the SDK installed.
|
|
436
|
+
"""
|
|
437
|
+
if self._client is None:
|
|
438
|
+
self._client = self._new_client()
|
|
439
|
+
return self._client
|
|
440
|
+
|
|
441
|
+
def _new_client(self) -> Any:
|
|
442
|
+
try:
|
|
443
|
+
import boto3
|
|
444
|
+
from botocore.config import Config
|
|
445
|
+
except ModuleNotFoundError as exc: # pragma: no cover - depends on the install
|
|
446
|
+
raise StorageBackendMissingError(
|
|
447
|
+
"boto3 is not installed, so no cloud backup can happen. Install the "
|
|
448
|
+
"storage extra (`uv sync --extra storage`), or unset CCTV_STORAGE_BUCKET "
|
|
449
|
+
"to run without cloud backup deliberately."
|
|
450
|
+
) from exc
|
|
451
|
+
|
|
452
|
+
return boto3.client(
|
|
453
|
+
"s3",
|
|
454
|
+
endpoint_url=self._endpoint_url,
|
|
455
|
+
region_name=self._region,
|
|
456
|
+
aws_access_key_id=self._access_key_id,
|
|
457
|
+
aws_secret_access_key=self._secret_access_key,
|
|
458
|
+
config=Config(
|
|
459
|
+
retries={"max_attempts": 3, "mode": "standard"},
|
|
460
|
+
connect_timeout=10,
|
|
461
|
+
read_timeout=60,
|
|
462
|
+
),
|
|
463
|
+
)
|
|
464
|
+
|
|
465
|
+
# -- operations --------------------------------------------------------------------
|
|
466
|
+
|
|
467
|
+
def ensure_bucket(self, *, configure_lifecycle: Any | None = None) -> None:
|
|
468
|
+
"""Create the bucket if it is absent. Lifecycle rules are left to `install.sh`.
|
|
469
|
+
|
|
470
|
+
Lifecycle configuration is the one genuinely provider-specific feature, so its
|
|
471
|
+
absence degrades to "no provider backstop" with a warning rather than a failure
|
|
472
|
+
(cloud-object-layout §6.1) — which also means an S3-compatible test double needs nothing
|
|
473
|
+
special.
|
|
474
|
+
"""
|
|
475
|
+
try:
|
|
476
|
+
self.client.head_bucket(Bucket=self._bucket)
|
|
477
|
+
return
|
|
478
|
+
except StorageError:
|
|
479
|
+
raise
|
|
480
|
+
except Exception as exc:
|
|
481
|
+
classified = classify_client_error(exc)
|
|
482
|
+
if not isinstance(classified, BucketNotFoundError):
|
|
483
|
+
raise classified from exc
|
|
484
|
+
try:
|
|
485
|
+
self.client.create_bucket(Bucket=self._bucket)
|
|
486
|
+
except StorageError:
|
|
487
|
+
raise
|
|
488
|
+
except Exception as exc:
|
|
489
|
+
raise classify_client_error(exc) from exc
|
|
490
|
+
|
|
491
|
+
def put_object(
|
|
492
|
+
self,
|
|
493
|
+
*,
|
|
494
|
+
key: str,
|
|
495
|
+
path: Path,
|
|
496
|
+
metadata: Mapping[str, str],
|
|
497
|
+
checksum_sha256: str,
|
|
498
|
+
) -> PutResult:
|
|
499
|
+
"""Upload `path` to `key`, idempotently, verifying what we can and saying what.
|
|
500
|
+
|
|
501
|
+
Order matters and is deliberate:
|
|
502
|
+
|
|
503
|
+
1. `HEAD` first. An object already there with a matching checksum means the bytes
|
|
504
|
+
need not be sent at all — this is the crash-between-PUT-and-record recovery
|
|
505
|
+
([INV-7](../../../docs/architecture/invariants.md)).
|
|
506
|
+
2. If it is there with *unprovable* content, refuse. Never overwrite.
|
|
507
|
+
3. Upload, then compare the provider's checksum when it gives us a comparable one.
|
|
508
|
+
4. When it does not (multipart), fall back to size plus our own recorded metadata
|
|
509
|
+
and record `verified_by="size"` — never `"sha256"`.
|
|
510
|
+
"""
|
|
511
|
+
path = Path(path)
|
|
512
|
+
size = path.stat().st_size
|
|
513
|
+
digest_b64 = _b64(checksum_sha256)
|
|
514
|
+
stored_metadata = {str(k): str(v) for k, v in metadata.items()} | {
|
|
515
|
+
"sha256": checksum_sha256
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
existing = self.head_object(key=key)
|
|
519
|
+
if existing is not None:
|
|
520
|
+
recorded = existing.metadata.get("sha256")
|
|
521
|
+
if recorded == checksum_sha256 and existing.size == size:
|
|
522
|
+
return PutResult(
|
|
523
|
+
key=key,
|
|
524
|
+
size=existing.size,
|
|
525
|
+
verified_by="sha256",
|
|
526
|
+
etag=existing.etag,
|
|
527
|
+
already_present=True,
|
|
528
|
+
)
|
|
529
|
+
raise ObjectConflictError(
|
|
530
|
+
f"An object already exists at {key!r} with different content "
|
|
531
|
+
f"(local {size} bytes / sha256 {checksum_sha256[:12]}…, "
|
|
532
|
+
f"remote {existing.size} bytes / sha256 "
|
|
533
|
+
f"{(recorded or 'unrecorded')[:12]}…). Refusing to overwrite it: silently "
|
|
534
|
+
f"replacing an object is how the only good copy is lost. Inspect the object "
|
|
535
|
+
f"in the bucket and resolve it by hand."
|
|
536
|
+
)
|
|
537
|
+
|
|
538
|
+
try:
|
|
539
|
+
if size >= self._multipart_threshold:
|
|
540
|
+
self._multipart_upload(key=key, path=path, metadata=stored_metadata)
|
|
541
|
+
return self._verify_by_head(key=key, size=size, checksum_sha256=checksum_sha256)
|
|
542
|
+
|
|
543
|
+
with open(path, "rb") as handle:
|
|
544
|
+
response = self.client.put_object(
|
|
545
|
+
Bucket=self._bucket,
|
|
546
|
+
Key=key,
|
|
547
|
+
Body=handle,
|
|
548
|
+
ContentLength=size,
|
|
549
|
+
ContentType=CONTENT_TYPE_MP4,
|
|
550
|
+
Metadata=stored_metadata,
|
|
551
|
+
ChecksumSHA256=digest_b64,
|
|
552
|
+
)
|
|
553
|
+
except StorageError:
|
|
554
|
+
raise
|
|
555
|
+
except OSError as exc:
|
|
556
|
+
raise TransientStorageError(f"Could not read {path} to upload it: {exc}") from exc
|
|
557
|
+
except Exception as exc:
|
|
558
|
+
raise classify_client_error(exc, key=key) from exc
|
|
559
|
+
|
|
560
|
+
returned = response.get("ChecksumSHA256")
|
|
561
|
+
if returned:
|
|
562
|
+
if returned != digest_b64:
|
|
563
|
+
raise ChecksumMismatchError(
|
|
564
|
+
f"The provider stored different bytes for {key!r}: it reports checksum "
|
|
565
|
+
f"{returned!r}, we sent {digest_b64!r}. The local file is preserved and "
|
|
566
|
+
f"the clip will not be retried blindly."
|
|
567
|
+
)
|
|
568
|
+
return PutResult(
|
|
569
|
+
key=key,
|
|
570
|
+
size=size,
|
|
571
|
+
verified_by="sha256",
|
|
572
|
+
etag=response.get("ETag"),
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
# No comparable checksum came back. Do not claim one: fall back to a HEAD and
|
|
576
|
+
# compare size and our own recorded metadata.
|
|
577
|
+
return self._verify_by_head(key=key, size=size, checksum_sha256=checksum_sha256)
|
|
578
|
+
|
|
579
|
+
def head_object(self, *, key: str) -> ObjectInfo | None:
|
|
580
|
+
try:
|
|
581
|
+
response = self.client.head_object(Bucket=self._bucket, Key=key)
|
|
582
|
+
except StorageError:
|
|
583
|
+
raise
|
|
584
|
+
except Exception as exc:
|
|
585
|
+
classified = classify_client_error(exc, key=key)
|
|
586
|
+
if isinstance(classified, BucketNotFoundError):
|
|
587
|
+
# A missing *object* is not an error, it is the answer to the question.
|
|
588
|
+
return None
|
|
589
|
+
raise classified from exc
|
|
590
|
+
|
|
591
|
+
return ObjectInfo(
|
|
592
|
+
key=key,
|
|
593
|
+
size=int(response.get("ContentLength") or 0),
|
|
594
|
+
etag=response.get("ETag"),
|
|
595
|
+
metadata=dict(response.get("Metadata") or {}),
|
|
596
|
+
)
|
|
597
|
+
|
|
598
|
+
def delete_object(self, *, key: str) -> DeleteResult:
|
|
599
|
+
"""Delete, then **confirm** it is gone.
|
|
600
|
+
|
|
601
|
+
The confirmation is not belt-and-braces: on a versioned bucket a successful DELETE
|
|
602
|
+
response can mean "hidden", with the bytes still present and still billed
|
|
603
|
+
(cloud-object-layout §5.3).
|
|
604
|
+
"""
|
|
605
|
+
try:
|
|
606
|
+
self.client.delete_object(Bucket=self._bucket, Key=key)
|
|
607
|
+
except StorageError:
|
|
608
|
+
raise
|
|
609
|
+
except Exception as exc:
|
|
610
|
+
raise classify_client_error(exc, key=key) from exc
|
|
611
|
+
|
|
612
|
+
remaining = self.head_object(key=key)
|
|
613
|
+
if remaining is None:
|
|
614
|
+
return DeleteResult(key=key, deleted=True)
|
|
615
|
+
return DeleteResult(
|
|
616
|
+
key=key,
|
|
617
|
+
deleted=False,
|
|
618
|
+
detail=(
|
|
619
|
+
f"The DELETE was accepted but the object is still present ({remaining.size} "
|
|
620
|
+
f"bytes). On a versioned bucket this is a hide marker: the bytes still count "
|
|
621
|
+
f"toward storage. Configure the bucket to keep only the current version, or "
|
|
622
|
+
f"rely on the lifecycle rule's daysFromHidingToDeleting."
|
|
623
|
+
),
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
def list_objects(self, *, prefix: str, max_keys: int = 1000) -> Iterator[ObjectInfo]:
|
|
627
|
+
"""List objects. Reconciliation and restore only — never to decide what to delete.
|
|
628
|
+
|
|
629
|
+
`INV-10 <../../../../docs/architecture/invariants.md>` forbids acting on a listing.
|
|
630
|
+
"""
|
|
631
|
+
token: str | None = None
|
|
632
|
+
while True:
|
|
633
|
+
parameters: dict[str, Any] = {
|
|
634
|
+
"Bucket": self._bucket,
|
|
635
|
+
"Prefix": prefix,
|
|
636
|
+
"MaxKeys": min(max_keys, 1000),
|
|
637
|
+
}
|
|
638
|
+
if token:
|
|
639
|
+
parameters["ContinuationToken"] = token
|
|
640
|
+
try:
|
|
641
|
+
response = self.client.list_objects_v2(**parameters)
|
|
642
|
+
except StorageError:
|
|
643
|
+
raise
|
|
644
|
+
except Exception as exc:
|
|
645
|
+
raise classify_client_error(exc) from exc
|
|
646
|
+
|
|
647
|
+
for item in response.get("Contents") or []:
|
|
648
|
+
yield ObjectInfo(
|
|
649
|
+
key=str(item["Key"]),
|
|
650
|
+
size=int(item.get("Size") or 0),
|
|
651
|
+
etag=item.get("ETag"),
|
|
652
|
+
)
|
|
653
|
+
if not response.get("IsTruncated"):
|
|
654
|
+
return
|
|
655
|
+
token = response.get("NextContinuationToken")
|
|
656
|
+
if not token:
|
|
657
|
+
return
|
|
658
|
+
|
|
659
|
+
def presign_get(self, *, key: str, ttl_s: int) -> str:
|
|
660
|
+
"""A time-limited GET URL. Unused in v1; present so a clip browser needs no surgery."""
|
|
661
|
+
try:
|
|
662
|
+
return str(
|
|
663
|
+
self.client.generate_presigned_url(
|
|
664
|
+
"get_object",
|
|
665
|
+
Params={"Bucket": self._bucket, "Key": key},
|
|
666
|
+
ExpiresIn=int(ttl_s),
|
|
667
|
+
)
|
|
668
|
+
)
|
|
669
|
+
except StorageError:
|
|
670
|
+
raise
|
|
671
|
+
except Exception as exc:
|
|
672
|
+
raise classify_client_error(exc, key=key) from exc
|
|
673
|
+
|
|
674
|
+
def healthcheck(self) -> AdapterHealth:
|
|
675
|
+
"""Is the endpoint reachable, are the credentials good, does the bucket exist?"""
|
|
676
|
+
started = time.perf_counter()
|
|
677
|
+
try:
|
|
678
|
+
self.client.head_bucket(Bucket=self._bucket)
|
|
679
|
+
except StorageError as exc:
|
|
680
|
+
return self._unhealthy(exc, started)
|
|
681
|
+
except Exception as exc:
|
|
682
|
+
return self._unhealthy(classify_client_error(exc), started)
|
|
683
|
+
return AdapterHealth(
|
|
684
|
+
reachable=True,
|
|
685
|
+
credentials_ok=True,
|
|
686
|
+
bucket_present=True,
|
|
687
|
+
detail=f"bucket {self._bucket!r} reachable",
|
|
688
|
+
latency_ms=int((time.perf_counter() - started) * 1000),
|
|
689
|
+
)
|
|
690
|
+
|
|
691
|
+
def _unhealthy(self, exc: StorageError, started: float) -> AdapterHealth:
|
|
692
|
+
# A missing SDK was never able to reach anything, so all three answers are "no"
|
|
693
|
+
# rather than the optimistic defaults a failure to *contact* would imply.
|
|
694
|
+
missing_backend = isinstance(exc, StorageBackendMissingError)
|
|
695
|
+
return AdapterHealth(
|
|
696
|
+
# An auth failure proves the endpoint is reachable — we are just not allowed in.
|
|
697
|
+
reachable=not isinstance(exc, TransientStorageError) and not missing_backend,
|
|
698
|
+
credentials_ok=not isinstance(exc, AuthStorageError) and not missing_backend,
|
|
699
|
+
bucket_present=not isinstance(exc, BucketNotFoundError) and not missing_backend,
|
|
700
|
+
detail=str(exc),
|
|
701
|
+
latency_ms=int((time.perf_counter() - started) * 1000),
|
|
702
|
+
error=exc,
|
|
703
|
+
)
|
|
704
|
+
|
|
705
|
+
# -- internals ---------------------------------------------------------------------
|
|
706
|
+
|
|
707
|
+
def _multipart_upload(self, *, key: str, path: Path, metadata: Mapping[str, str]) -> None:
|
|
708
|
+
config = self._transfer_config()
|
|
709
|
+
extra: dict[str, Any] = {} if config is None else {"Config": config}
|
|
710
|
+
self.client.upload_file(
|
|
711
|
+
str(path),
|
|
712
|
+
self._bucket,
|
|
713
|
+
key,
|
|
714
|
+
ExtraArgs={"ContentType": CONTENT_TYPE_MP4, "Metadata": dict(metadata)},
|
|
715
|
+
**extra,
|
|
716
|
+
)
|
|
717
|
+
|
|
718
|
+
def _transfer_config(self) -> Any | None:
|
|
719
|
+
"""Tune the multipart thresholds, or fall back to boto3's own defaults.
|
|
720
|
+
|
|
721
|
+
Imported here rather than at module scope so that the adapter is usable — and
|
|
722
|
+
testable — without `boto3` installed at all; the multipart path is driven through
|
|
723
|
+
`upload_file` by a fake client in the tests.
|
|
724
|
+
"""
|
|
725
|
+
try:
|
|
726
|
+
from boto3.s3.transfer import TransferConfig
|
|
727
|
+
except ModuleNotFoundError: # pragma: no cover - depends on the install
|
|
728
|
+
return None
|
|
729
|
+
return TransferConfig(
|
|
730
|
+
multipart_threshold=self._multipart_threshold,
|
|
731
|
+
multipart_chunksize=16 * 1024 * 1024,
|
|
732
|
+
use_threads=True,
|
|
733
|
+
)
|
|
734
|
+
|
|
735
|
+
def _verify_by_head(self, *, key: str, size: int, checksum_sha256: str) -> PutResult:
|
|
736
|
+
info = self.head_object(key=key)
|
|
737
|
+
if (
|
|
738
|
+
info is not None
|
|
739
|
+
and info.size == size
|
|
740
|
+
and info.metadata.get("sha256") == checksum_sha256
|
|
741
|
+
):
|
|
742
|
+
return PutResult(key=key, size=size, verified_by="size", etag=info.etag)
|
|
743
|
+
return PutResult(key=key, size=size, verified_by="none", etag=info.etag if info else None)
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def _b64(hex_digest: str) -> str:
|
|
747
|
+
"""Hex SHA-256 → the base64 form S3's `ChecksumSHA256` expects."""
|
|
748
|
+
try:
|
|
749
|
+
return base64.b64encode(bytes.fromhex(hex_digest)).decode("ascii")
|
|
750
|
+
except (ValueError, binascii.Error) as exc: # pragma: no cover - defensive
|
|
751
|
+
raise ValueError(f"Not a hex SHA-256 digest: {hex_digest!r}") from exc
|