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