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/__init__.py +1 -0
- dploydb/__main__.py +6 -0
- dploydb/backup.py +455 -0
- dploydb/candidate.py +868 -0
- dploydb/cli.py +1111 -0
- dploydb/config.py +784 -0
- dploydb/cutover.py +326 -0
- dploydb/deploy.py +1163 -0
- dploydb/deployment_dependencies.py +366 -0
- dploydb/deployment_evidence.py +136 -0
- dploydb/diagnostics.py +1020 -0
- dploydb/errors.py +140 -0
- dploydb/health.py +625 -0
- dploydb/locking.py +609 -0
- dploydb/manual_restore.py +825 -0
- dploydb/migration.py +575 -0
- dploydb/models.py +927 -0
- dploydb/recovery.py +1210 -0
- dploydb/redaction.py +229 -0
- dploydb/releases.py +611 -0
- dploydb/restore.py +461 -0
- dploydb/retention.py +165 -0
- dploydb/runners/__init__.py +33 -0
- dploydb/runners/base.py +389 -0
- dploydb/runners/docker_compose.py +590 -0
- dploydb/runners/docker_compose_production.py +966 -0
- dploydb/sqlite_checks.py +136 -0
- dploydb/state.py +604 -0
- dploydb/storage/__init__.py +13 -0
- dploydb/storage/base.py +52 -0
- dploydb/storage/local.py +301 -0
- dploydb/storage/s3.py +737 -0
- dploydb/subprocesses.py +641 -0
- dploydb/traffic.py +165 -0
- dploydb-0.1.0.dist-info/METADATA +583 -0
- dploydb-0.1.0.dist-info/RECORD +40 -0
- dploydb-0.1.0.dist-info/WHEEL +4 -0
- dploydb-0.1.0.dist-info/entry_points.txt +2 -0
- dploydb-0.1.0.dist-info/licenses/LICENSE +201 -0
- dploydb-0.1.0.dist-info/licenses/NOTICE +4 -0
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
|
+
)
|