dploydb 0.1.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.
dploydb/storage/s3.py ADDED
@@ -0,0 +1,737 @@
1
+ """Verified metadata-last backup replication for S3-compatible services."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import builtins
6
+ import hashlib
7
+ import json
8
+ import os
9
+ import re
10
+ import stat
11
+ from collections.abc import Mapping
12
+ from pathlib import Path
13
+ from typing import Any, Final, Protocol, cast
14
+
15
+ import boto3 # type: ignore[import-untyped]
16
+ from botocore.client import Config # type: ignore[import-untyped]
17
+ from botocore.exceptions import BotoCoreError, ClientError # type: ignore[import-untyped]
18
+ from pydantic import ValidationError
19
+
20
+ from dploydb.config import RemoteBackupConfig
21
+ from dploydb.errors import ConfigurationError, OperationFailedError, SafetyCheckError
22
+ from dploydb.models import (
23
+ BackupArtifact,
24
+ BackupMetadata,
25
+ RemoteBackupArtifact,
26
+ RemoteBackupMetadata,
27
+ utc_now,
28
+ )
29
+ from dploydb.redaction import SecretRegistry
30
+ from dploydb.sqlite_checks import verify_sqlite_database
31
+ from dploydb.storage.local import FILE_MODE, MAX_METADATA_BYTES
32
+
33
+ _BACKUP_ID = re.compile(r"^backup_[0-9a-f]{32}$")
34
+ _NOT_FOUND_CODES: Final[frozenset[str]] = frozenset({"404", "NoSuchKey", "NotFound"})
35
+ _STREAM_CHUNK_BYTES: Final[int] = 1024 * 1024
36
+ _MAX_LISTED_BACKUPS: Final[int] = 100_000
37
+
38
+
39
+ class _StreamingBody(Protocol):
40
+ def read(self, amount: int | None = None) -> bytes: ...
41
+
42
+ def close(self) -> None: ...
43
+
44
+
45
+ class S3Client(Protocol):
46
+ """Small low-level client surface used by the adapter and its tests."""
47
+
48
+ def put_object(self, **kwargs: Any) -> dict[str, Any]: ...
49
+
50
+ def get_object(self, **kwargs: Any) -> dict[str, Any]: ...
51
+
52
+ def head_object(self, **kwargs: Any) -> dict[str, Any]: ...
53
+
54
+ def list_objects_v2(self, **kwargs: Any) -> dict[str, Any]: ...
55
+
56
+ def delete_object(self, **kwargs: Any) -> dict[str, Any]: ...
57
+
58
+
59
+ class S3BackupStorage:
60
+ """Replicate immutable verified backups with metadata as the commit marker."""
61
+
62
+ def __init__(
63
+ self,
64
+ *,
65
+ client: S3Client,
66
+ bucket: str,
67
+ prefix: str,
68
+ storage_class: str,
69
+ secrets: SecretRegistry,
70
+ ) -> None:
71
+ self._client = client
72
+ self.bucket = bucket
73
+ self.prefix = prefix.rstrip("/")
74
+ self.storage_class = storage_class
75
+ self.secrets = secrets
76
+
77
+ def __repr__(self) -> str:
78
+ return (
79
+ f"S3BackupStorage(bucket={self.bucket!r}, prefix={self.prefix!r}, credentials=<hidden>)"
80
+ )
81
+
82
+ def put(
83
+ self,
84
+ artifact: BackupArtifact,
85
+ *,
86
+ release_id: str | None = None,
87
+ ) -> RemoteBackupArtifact:
88
+ """Upload database bytes first and publish immutable metadata last."""
89
+ self._validate_id(artifact.metadata.backup_id)
90
+ self._verify_local_artifact(artifact)
91
+ backup_id = artifact.metadata.backup_id
92
+
93
+ try:
94
+ committed = self.verify_metadata(backup_id)
95
+ except SafetyCheckError as error:
96
+ if not self._metadata_missing(error, backup_id):
97
+ raise
98
+ else:
99
+ if committed.backup != artifact.metadata or committed.release_id != release_id:
100
+ raise self._verification_error(
101
+ backup_id,
102
+ "committed remote metadata contradicts the requested backup identity",
103
+ )
104
+ return self._artifact(committed)
105
+
106
+ database_key = self._database_key(backup_id)
107
+ if self._object_exists(database_key):
108
+ try:
109
+ self._verify_remote_database_bytes(database_key, artifact.metadata)
110
+ except SafetyCheckError:
111
+ self._delete_key(database_key)
112
+ if not self._object_exists(database_key):
113
+ self._upload_database(artifact, database_key)
114
+
115
+ try:
116
+ self._verify_remote_database_bytes(database_key, artifact.metadata)
117
+ record = RemoteBackupMetadata(
118
+ backup=artifact.metadata,
119
+ release_id=release_id,
120
+ database_object_key=database_key,
121
+ uploaded_at=utc_now(),
122
+ )
123
+ self._upload_metadata(record)
124
+ committed = self.verify_metadata(backup_id)
125
+ if committed != record:
126
+ raise self._verification_error(
127
+ backup_id,
128
+ "remote metadata changed while the backup was committed",
129
+ )
130
+ return self._artifact(committed)
131
+ except Exception as error:
132
+ cleanup_errors = self._cleanup_uncommitted(backup_id)
133
+ if cleanup_errors:
134
+ error.add_note(self.secrets.redact_text("; ".join(cleanup_errors)))
135
+ if isinstance(error, (OperationFailedError, SafetyCheckError)):
136
+ raise
137
+ raise self._operation_error(
138
+ backup_id,
139
+ f"remote backup commit failed: {type(error).__name__}: {error}",
140
+ ) from None
141
+
142
+ def download(self, backup_id: str, destination: Path) -> RemoteBackupArtifact:
143
+ """Download one committed object into a caller-owned private staging file."""
144
+ self._validate_id(backup_id)
145
+ record = self.verify_metadata(backup_id)
146
+ self._validate_download_destination(destination)
147
+ body: _StreamingBody | None = None
148
+ descriptor = -1
149
+ try:
150
+ response = self._client.get_object(
151
+ Bucket=self.bucket,
152
+ Key=record.database_object_key,
153
+ )
154
+ self._validate_database_response(response, record.backup)
155
+ body = cast(_StreamingBody, response["Body"])
156
+ flags = os.O_WRONLY | os.O_TRUNC
157
+ flags |= getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
158
+ descriptor = os.open(destination, flags)
159
+ os.fchmod(descriptor, FILE_MODE)
160
+ digest = hashlib.sha256()
161
+ size = 0
162
+ while True:
163
+ chunk = body.read(_STREAM_CHUNK_BYTES)
164
+ if not chunk:
165
+ break
166
+ _write_all(descriptor, chunk)
167
+ digest.update(chunk)
168
+ size += len(chunk)
169
+ os.fsync(descriptor)
170
+ os.close(descriptor)
171
+ descriptor = -1
172
+ if size != record.backup.size_bytes or digest.hexdigest() != record.backup.sha256:
173
+ raise self._verification_error(
174
+ backup_id,
175
+ "downloaded remote backup SHA-256 or size does not match metadata",
176
+ )
177
+ verify_sqlite_database(destination)
178
+ return self._artifact(record)
179
+ except (ClientError, BotoCoreError, OSError, KeyError, TypeError) as error:
180
+ self._remove_failed_download(destination)
181
+ raise self._operation_error(
182
+ backup_id,
183
+ f"remote backup download failed: {type(error).__name__}: {error}",
184
+ ) from None
185
+ except Exception:
186
+ self._remove_failed_download(destination)
187
+ raise
188
+ finally:
189
+ if descriptor >= 0:
190
+ os.close(descriptor)
191
+ if body is not None:
192
+ body.close()
193
+
194
+ def exists(self, backup_id: str) -> bool:
195
+ self._validate_id(backup_id)
196
+ try:
197
+ self._client.head_object(Bucket=self.bucket, Key=self._metadata_key(backup_id))
198
+ except ClientError as error:
199
+ if _is_not_found(error):
200
+ return False
201
+ raise self._operation_error(
202
+ backup_id,
203
+ f"remote metadata existence check failed: {error}",
204
+ ) from None
205
+ except BotoCoreError as error:
206
+ raise self._operation_error(
207
+ backup_id,
208
+ f"remote metadata existence check failed: {error}",
209
+ ) from None
210
+ return True
211
+
212
+ def probe_access(self) -> None:
213
+ """Perform one bounded read-only request against the configured bucket."""
214
+ prefix = f"{self.prefix}/" if self.prefix else ""
215
+ try:
216
+ response = self._client.list_objects_v2(
217
+ Bucket=self.bucket,
218
+ Prefix=prefix,
219
+ MaxKeys=1,
220
+ )
221
+ if not isinstance(response, dict):
222
+ raise TypeError("S3 access probe returned an invalid response")
223
+ except (ClientError, BotoCoreError, TypeError) as error:
224
+ raise self._operation_error(
225
+ "probe",
226
+ f"remote backup access probe failed: {type(error).__name__}: {error}",
227
+ ) from None
228
+
229
+ def list(self) -> tuple[RemoteBackupMetadata, ...]:
230
+ prefix = f"{self.prefix}/" if self.prefix else ""
231
+ continuation: str | None = None
232
+ backup_ids: list[str] = []
233
+ try:
234
+ while True:
235
+ arguments: dict[str, Any] = {
236
+ "Bucket": self.bucket,
237
+ "Prefix": prefix,
238
+ "MaxKeys": 1000,
239
+ }
240
+ if continuation is not None:
241
+ arguments["ContinuationToken"] = continuation
242
+ response = self._client.list_objects_v2(**arguments)
243
+ contents = response.get("Contents", [])
244
+ if not isinstance(contents, list):
245
+ raise TypeError("S3 list response contents are invalid")
246
+ for item in contents:
247
+ if not isinstance(item, dict) or not isinstance(item.get("Key"), str):
248
+ raise TypeError("S3 list response object is invalid")
249
+ key = item["Key"]
250
+ name = key.removeprefix(prefix)
251
+ if "/" not in name and name.startswith("backup_") and name.endswith(".json"):
252
+ backup_id = name.removesuffix(".json")
253
+ self._validate_id(backup_id)
254
+ backup_ids.append(backup_id)
255
+ if len(backup_ids) > _MAX_LISTED_BACKUPS:
256
+ raise ValueError("remote backup listing exceeds the safety limit")
257
+ if not response.get("IsTruncated"):
258
+ break
259
+ token = response.get("NextContinuationToken")
260
+ if not isinstance(token, str) or not token:
261
+ raise TypeError("truncated S3 listing omitted its continuation token")
262
+ continuation = token
263
+ except (ClientError, BotoCoreError, TypeError, ValueError) as error:
264
+ raise self._operation_error(
265
+ "listing",
266
+ f"remote backup listing failed: {type(error).__name__}: {error}",
267
+ ) from None
268
+ return tuple(self.verify_metadata(backup_id) for backup_id in sorted(set(backup_ids)))
269
+
270
+ def delete(self, backup_id: str) -> None:
271
+ """Delete metadata first so interrupted cleanup never advertises missing bytes."""
272
+ self._validate_id(backup_id)
273
+ metadata_key = self._metadata_key(backup_id)
274
+ database_key = self._database_key(backup_id)
275
+ self._delete_key(metadata_key)
276
+ self._delete_key(database_key)
277
+ remaining = [key for key in (metadata_key, database_key) if self._object_exists(key)]
278
+ if remaining:
279
+ raise self._operation_error(
280
+ backup_id,
281
+ "remote object deletion returned without proving every backup object absent",
282
+ )
283
+
284
+ def verify_metadata(self, backup_id: str) -> RemoteBackupMetadata:
285
+ self._validate_id(backup_id)
286
+ key = self._metadata_key(backup_id)
287
+ body: _StreamingBody | None = None
288
+ try:
289
+ response = self._client.get_object(Bucket=self.bucket, Key=key)
290
+ body = cast(_StreamingBody, response["Body"])
291
+ payload = _read_bounded(body, MAX_METADATA_BYTES)
292
+ if not payload.endswith(b"\n"):
293
+ raise ValueError("remote backup metadata is truncated")
294
+ record = RemoteBackupMetadata.model_validate_json(payload)
295
+ except ClientError as error:
296
+ if _is_not_found(error):
297
+ raise self._verification_error(
298
+ backup_id, "remote backup metadata is missing"
299
+ ) from None
300
+ raise self._operation_error(
301
+ backup_id,
302
+ f"remote metadata read failed: {error}",
303
+ ) from None
304
+ except BotoCoreError as error:
305
+ raise self._operation_error(
306
+ backup_id,
307
+ f"remote metadata read failed: {error}",
308
+ ) from None
309
+ except (KeyError, TypeError, ValueError, ValidationError) as error:
310
+ raise self._verification_error(
311
+ backup_id,
312
+ f"remote backup metadata is invalid: {error}",
313
+ ) from None
314
+ finally:
315
+ if body is not None:
316
+ body.close()
317
+ if record.backup.backup_id != backup_id:
318
+ raise self._verification_error(
319
+ backup_id,
320
+ "remote backup metadata ID does not match its object key",
321
+ )
322
+ if record.database_object_key != self._database_key(backup_id):
323
+ raise self._verification_error(
324
+ backup_id,
325
+ "remote backup metadata points outside its configured object key",
326
+ )
327
+ try:
328
+ head = self._client.head_object(
329
+ Bucket=self.bucket,
330
+ Key=record.database_object_key,
331
+ )
332
+ self._validate_database_response(head, record.backup)
333
+ except ClientError as error:
334
+ if _is_not_found(error):
335
+ raise self._verification_error(
336
+ backup_id,
337
+ "remote backup metadata exists but database bytes are missing",
338
+ ) from None
339
+ raise self._operation_error(
340
+ backup_id,
341
+ f"remote database metadata check failed: {error}",
342
+ ) from None
343
+ except BotoCoreError as error:
344
+ raise self._operation_error(
345
+ backup_id,
346
+ f"remote database metadata check failed: {error}",
347
+ ) from None
348
+ return record
349
+
350
+ def _upload_database(self, artifact: BackupArtifact, key: str) -> None:
351
+ arguments: dict[str, Any] = {
352
+ "Bucket": self.bucket,
353
+ "Key": key,
354
+ "ContentLength": artifact.metadata.size_bytes,
355
+ "ContentType": "application/vnd.sqlite3",
356
+ "Metadata": _database_headers(artifact.metadata),
357
+ }
358
+ if self.storage_class != "STANDARD":
359
+ arguments["StorageClass"] = self.storage_class
360
+ try:
361
+ with artifact.database_path.open("rb") as source:
362
+ arguments["Body"] = source
363
+ self._client.put_object(**arguments)
364
+ except (ClientError, BotoCoreError, OSError) as error:
365
+ raise self._operation_error(
366
+ artifact.metadata.backup_id,
367
+ f"remote database upload failed: {error}",
368
+ ) from None
369
+
370
+ def _upload_metadata(self, record: RemoteBackupMetadata) -> None:
371
+ payload = _serialize_remote_metadata(record)
372
+ arguments: dict[str, Any] = {
373
+ "Bucket": self.bucket,
374
+ "Key": self._metadata_key(record.backup.backup_id),
375
+ "Body": payload,
376
+ "ContentLength": len(payload),
377
+ "ContentType": "application/json",
378
+ "Metadata": {
379
+ "backup-id": record.backup.backup_id,
380
+ "sha256": record.backup.sha256,
381
+ },
382
+ }
383
+ if self.storage_class != "STANDARD":
384
+ arguments["StorageClass"] = self.storage_class
385
+ try:
386
+ self._client.put_object(**arguments)
387
+ except (ClientError, BotoCoreError) as error:
388
+ raise self._operation_error(
389
+ record.backup.backup_id,
390
+ f"remote metadata upload failed: {error}",
391
+ ) from None
392
+
393
+ def _verify_remote_database_bytes(self, key: str, metadata: BackupMetadata) -> None:
394
+ body: _StreamingBody | None = None
395
+ try:
396
+ response = self._client.get_object(Bucket=self.bucket, Key=key)
397
+ self._validate_database_response(response, metadata)
398
+ body = cast(_StreamingBody, response["Body"])
399
+ digest = hashlib.sha256()
400
+ size = 0
401
+ while True:
402
+ chunk = body.read(_STREAM_CHUNK_BYTES)
403
+ if not chunk:
404
+ break
405
+ digest.update(chunk)
406
+ size += len(chunk)
407
+ except ClientError as error:
408
+ if _is_not_found(error):
409
+ raise self._verification_error(
410
+ metadata.backup_id,
411
+ "remote database bytes are missing",
412
+ ) from None
413
+ raise self._operation_error(
414
+ metadata.backup_id,
415
+ f"remote database readback failed: {error}",
416
+ ) from None
417
+ except BotoCoreError as error:
418
+ raise self._operation_error(
419
+ metadata.backup_id,
420
+ f"remote database readback failed: {error}",
421
+ ) from None
422
+ finally:
423
+ if body is not None:
424
+ body.close()
425
+ if size != metadata.size_bytes or digest.hexdigest() != metadata.sha256:
426
+ raise self._verification_error(
427
+ metadata.backup_id,
428
+ "remote database readback SHA-256 or size does not match local metadata",
429
+ )
430
+
431
+ def _validate_database_response(
432
+ self,
433
+ response: Mapping[str, Any],
434
+ metadata: BackupMetadata,
435
+ ) -> None:
436
+ content_length = response.get("ContentLength")
437
+ headers = response.get("Metadata")
438
+ if content_length != metadata.size_bytes:
439
+ raise self._verification_error(
440
+ metadata.backup_id,
441
+ "remote database object size does not match backup metadata",
442
+ )
443
+ if not isinstance(headers, Mapping):
444
+ raise self._verification_error(
445
+ metadata.backup_id,
446
+ "remote database object metadata is missing",
447
+ )
448
+ normalized = {str(key).lower(): str(value) for key, value in headers.items()}
449
+ expected = _database_headers(metadata)
450
+ if any(normalized.get(key) != value for key, value in expected.items()):
451
+ raise self._verification_error(
452
+ metadata.backup_id,
453
+ "remote database object metadata contradicts the backup identity",
454
+ )
455
+
456
+ def _object_exists(self, key: str) -> bool:
457
+ try:
458
+ self._client.head_object(Bucket=self.bucket, Key=key)
459
+ except ClientError as error:
460
+ if _is_not_found(error):
461
+ return False
462
+ raise self._operation_error(key, f"remote object check failed: {error}") from None
463
+ except BotoCoreError as error:
464
+ raise self._operation_error(key, f"remote object check failed: {error}") from None
465
+ return True
466
+
467
+ def _delete_key(self, key: str) -> None:
468
+ try:
469
+ self._client.delete_object(Bucket=self.bucket, Key=key)
470
+ except (ClientError, BotoCoreError) as error:
471
+ raise self._operation_error(key, f"remote object deletion failed: {error}") from None
472
+
473
+ def _cleanup_uncommitted(self, backup_id: str) -> builtins.list[str]:
474
+ errors: builtins.list[str] = []
475
+ for key in (self._metadata_key(backup_id), self._database_key(backup_id)):
476
+ try:
477
+ self._delete_key(key)
478
+ except OperationFailedError as error:
479
+ errors.append(error.payload.what_failed)
480
+ return errors
481
+
482
+ def _artifact(self, metadata: RemoteBackupMetadata) -> RemoteBackupArtifact:
483
+ return RemoteBackupArtifact(
484
+ metadata=metadata,
485
+ bucket=self.bucket,
486
+ metadata_object_key=self._metadata_key(metadata.backup.backup_id),
487
+ )
488
+
489
+ def _database_key(self, backup_id: str) -> str:
490
+ name = f"{backup_id}.db"
491
+ return f"{self.prefix}/{name}" if self.prefix else name
492
+
493
+ def _metadata_key(self, backup_id: str) -> str:
494
+ name = f"{backup_id}.json"
495
+ return f"{self.prefix}/{name}" if self.prefix else name
496
+
497
+ def _validate_id(self, backup_id: str) -> None:
498
+ if _BACKUP_ID.fullmatch(backup_id) is None:
499
+ raise self._verification_error(backup_id, "remote backup ID is invalid")
500
+
501
+ def _verify_local_artifact(self, artifact: BackupArtifact) -> None:
502
+ from dploydb.backup import calculate_sha256
503
+
504
+ try:
505
+ details = artifact.database_path.lstat()
506
+ except OSError as error:
507
+ raise self._verification_error(
508
+ artifact.metadata.backup_id,
509
+ f"local backup is unavailable before upload: {error}",
510
+ ) from None
511
+ if artifact.database_path.is_symlink() or not stat.S_ISREG(details.st_mode):
512
+ raise self._verification_error(
513
+ artifact.metadata.backup_id,
514
+ "local backup must be a regular non-symlink file before upload",
515
+ )
516
+ size, sha256 = calculate_sha256(artifact.database_path)
517
+ if size != artifact.metadata.size_bytes or sha256 != artifact.metadata.sha256:
518
+ raise self._verification_error(
519
+ artifact.metadata.backup_id,
520
+ "local backup changed before remote upload",
521
+ )
522
+ verify_sqlite_database(artifact.database_path)
523
+
524
+ def _validate_download_destination(self, destination: Path) -> None:
525
+ if not destination.is_absolute():
526
+ raise ValueError("remote download destination must be absolute")
527
+ try:
528
+ details = destination.lstat()
529
+ except OSError as error:
530
+ raise self._operation_error(
531
+ destination.name,
532
+ f"remote download staging file is unavailable: {error}",
533
+ ) from None
534
+ if (
535
+ destination.is_symlink()
536
+ or not stat.S_ISREG(details.st_mode)
537
+ or stat.S_IMODE(details.st_mode) != FILE_MODE
538
+ ):
539
+ raise self._operation_error(
540
+ destination.name,
541
+ "remote download requires a private mode-0600 regular staging file",
542
+ )
543
+
544
+ @staticmethod
545
+ def _remove_failed_download(destination: Path) -> None:
546
+ try:
547
+ destination.unlink(missing_ok=True)
548
+ except OSError:
549
+ pass
550
+
551
+ def _metadata_missing(self, error: SafetyCheckError, backup_id: str) -> bool:
552
+ return (
553
+ "metadata is missing" in error.payload.what_failed
554
+ and error.payload.log_path == self._location(self._metadata_key(backup_id))
555
+ )
556
+
557
+ def _operation_error(self, identity: str, detail: str) -> OperationFailedError:
558
+ return OperationFailedError(
559
+ self.secrets.redact_text(detail),
560
+ production_changed=False,
561
+ previous_application_running=None,
562
+ log_path=self._location(identity),
563
+ next_safe_action=(
564
+ "Production was not changed; correct remote backup storage and retry."
565
+ ),
566
+ )
567
+
568
+ def _verification_error(self, backup_id: str, detail: str) -> SafetyCheckError:
569
+ key = self._metadata_key(backup_id) if _BACKUP_ID.fullmatch(backup_id) else backup_id
570
+ return SafetyCheckError(
571
+ self.secrets.redact_text(detail),
572
+ production_changed=False,
573
+ previous_application_running=None,
574
+ log_path=self._location(key),
575
+ next_safe_action=(
576
+ "Do not restore or retain this remote object as verified; use another backup."
577
+ ),
578
+ )
579
+
580
+ def _location(self, key: str) -> str:
581
+ return f"s3://{self.bucket}/{key}"
582
+
583
+
584
+ def configured_s3_storage(
585
+ remote: RemoteBackupConfig,
586
+ *,
587
+ secrets: SecretRegistry,
588
+ environment: Mapping[str, str],
589
+ client: S3Client | None = None,
590
+ ) -> S3BackupStorage:
591
+ """Resolve runtime-only credentials and construct an R2/S3-compatible adapter."""
592
+ if not remote.enabled:
593
+ raise _configuration_error("remote backup is not enabled")
594
+ if remote.bucket is None or remote.access_key_env is None or remote.secret_key_env is None:
595
+ raise _configuration_error("enabled remote backup configuration is incomplete")
596
+
597
+ endpoint_url = remote.endpoint_url
598
+ if remote.endpoint_url_env is not None:
599
+ endpoint_url = _required_environment_value(
600
+ remote.endpoint_url_env,
601
+ environment,
602
+ label="remote endpoint",
603
+ )
604
+ access_key = _required_environment_value(
605
+ remote.access_key_env,
606
+ environment,
607
+ label="remote access key",
608
+ )
609
+ secret_key = _required_environment_value(
610
+ remote.secret_key_env,
611
+ environment,
612
+ label="remote secret key",
613
+ )
614
+ session_token = None
615
+ if remote.session_token_env is not None:
616
+ session_token = _required_environment_value(
617
+ remote.session_token_env,
618
+ environment,
619
+ label="remote session token",
620
+ )
621
+ secrets.register_many(
622
+ value for value in (access_key, secret_key, session_token) if value is not None
623
+ )
624
+
625
+ resolved = remote.model_dump(mode="python")
626
+ resolved["endpoint_url"] = endpoint_url
627
+ resolved["endpoint_url_env"] = None
628
+ try:
629
+ validated = RemoteBackupConfig.model_validate(resolved)
630
+ except ValidationError as error:
631
+ raise _configuration_error(
632
+ "resolved remote backup endpoint is invalid: "
633
+ + secrets.redact_text(str(error.errors(include_url=False, include_input=False)))
634
+ ) from None
635
+
636
+ selected_client = client
637
+ if selected_client is None:
638
+ boto_config = Config(
639
+ signature_version="s3v4",
640
+ connect_timeout=validated.timeout_seconds,
641
+ read_timeout=validated.timeout_seconds,
642
+ retries={
643
+ "mode": "standard",
644
+ "total_max_attempts": validated.max_attempts,
645
+ },
646
+ s3={"addressing_style": "path"},
647
+ )
648
+ selected_client = cast(
649
+ S3Client,
650
+ boto3.client(
651
+ "s3",
652
+ endpoint_url=validated.endpoint_url,
653
+ region_name=validated.region_name,
654
+ aws_access_key_id=access_key,
655
+ aws_secret_access_key=secret_key,
656
+ aws_session_token=session_token,
657
+ config=boto_config,
658
+ ),
659
+ )
660
+ return S3BackupStorage(
661
+ client=selected_client,
662
+ bucket=validated.bucket or "",
663
+ prefix=validated.prefix,
664
+ storage_class=validated.storage_class,
665
+ secrets=secrets,
666
+ )
667
+
668
+
669
+ def _required_environment_value(
670
+ name: str,
671
+ environment: Mapping[str, str],
672
+ *,
673
+ label: str,
674
+ ) -> str:
675
+ try:
676
+ value = environment[name]
677
+ except KeyError:
678
+ raise _configuration_error(f"{label} environment variable is missing: {name}") from None
679
+ if not value or "\x00" in value:
680
+ raise _configuration_error(f"{label} environment variable is empty or invalid: {name}")
681
+ return value
682
+
683
+
684
+ def _database_headers(metadata: BackupMetadata) -> dict[str, str]:
685
+ return {
686
+ "backup-id": metadata.backup_id,
687
+ "sha256": metadata.sha256,
688
+ "size-bytes": str(metadata.size_bytes),
689
+ "project": metadata.project,
690
+ }
691
+
692
+
693
+ def _serialize_remote_metadata(record: RemoteBackupMetadata) -> bytes:
694
+ payload = (
695
+ json.dumps(
696
+ record.model_dump(mode="json"),
697
+ sort_keys=True,
698
+ separators=(",", ":"),
699
+ ensure_ascii=False,
700
+ allow_nan=False,
701
+ ).encode("utf-8")
702
+ + b"\n"
703
+ )
704
+ if len(payload) > MAX_METADATA_BYTES:
705
+ raise ValueError("serialized remote backup metadata exceeds the size limit")
706
+ return payload
707
+
708
+
709
+ def _read_bounded(body: _StreamingBody, maximum: int) -> bytes:
710
+ payload = body.read(maximum + 1)
711
+ if len(payload) > maximum:
712
+ raise ValueError("remote backup metadata exceeds the size limit")
713
+ return payload
714
+
715
+
716
+ def _is_not_found(error: ClientError) -> bool:
717
+ code = str(error.response.get("Error", {}).get("Code", ""))
718
+ status = str(error.response.get("ResponseMetadata", {}).get("HTTPStatusCode", ""))
719
+ return code in _NOT_FOUND_CODES or status == "404"
720
+
721
+
722
+ def _write_all(descriptor: int, payload: bytes) -> None:
723
+ written = 0
724
+ while written < len(payload):
725
+ count = os.write(descriptor, payload[written:])
726
+ if count <= 0:
727
+ raise OSError("remote download write made no progress")
728
+ written += count
729
+
730
+
731
+ def _configuration_error(detail: str) -> ConfigurationError:
732
+ return ConfigurationError(
733
+ detail,
734
+ production_changed=False,
735
+ previous_application_running=None,
736
+ next_safe_action="Correct remote backup configuration and retry.",
737
+ )