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/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""DployDB package."""
|
dploydb/__main__.py
ADDED
dploydb/backup.py
ADDED
|
@@ -0,0 +1,455 @@
|
|
|
1
|
+
"""Consistent SQLite online snapshots and immutable verification."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import os
|
|
7
|
+
import sqlite3
|
|
8
|
+
import stat
|
|
9
|
+
import tempfile
|
|
10
|
+
import time
|
|
11
|
+
from collections.abc import Iterator, Mapping
|
|
12
|
+
from contextlib import contextmanager
|
|
13
|
+
from dataclasses import dataclass
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Final
|
|
16
|
+
|
|
17
|
+
from dploydb.config import LoadedConfiguration, configuration_fingerprint
|
|
18
|
+
from dploydb.errors import (
|
|
19
|
+
DployDBError,
|
|
20
|
+
OperationFailedError,
|
|
21
|
+
RecoveryRequiredError,
|
|
22
|
+
SafetyCheckError,
|
|
23
|
+
)
|
|
24
|
+
from dploydb.locking import DeploymentLock
|
|
25
|
+
from dploydb.models import (
|
|
26
|
+
BackupArtifact,
|
|
27
|
+
BackupMetadata,
|
|
28
|
+
BackupPurpose,
|
|
29
|
+
FailureRecord,
|
|
30
|
+
LockOwnerState,
|
|
31
|
+
OperationStatus,
|
|
32
|
+
RemoteBackupArtifact,
|
|
33
|
+
SafetyFacts,
|
|
34
|
+
new_backup_id,
|
|
35
|
+
utc_now,
|
|
36
|
+
)
|
|
37
|
+
from dploydb.sqlite_checks import DEFAULT_SQLITE_TIMEOUT_SECONDS, verify_sqlite_database
|
|
38
|
+
from dploydb.state import StateStore
|
|
39
|
+
from dploydb.storage.base import BackupStorage, RemoteBackupStorage
|
|
40
|
+
from dploydb.storage.local import LocalBackupStorage
|
|
41
|
+
from dploydb.storage.s3 import configured_s3_storage
|
|
42
|
+
|
|
43
|
+
BACKUP_TIMEOUT_SECONDS: Final[float] = 120.0
|
|
44
|
+
COPY_PAGES: Final[int] = 256
|
|
45
|
+
HASH_CHUNK_BYTES: Final[int] = 1024 * 1024
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True, slots=True)
|
|
49
|
+
class ConfiguredBackupResult:
|
|
50
|
+
"""Verified local backup plus optional committed remote replica."""
|
|
51
|
+
|
|
52
|
+
local: BackupArtifact
|
|
53
|
+
remote: RemoteBackupArtifact | None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def create_configured_backup(loaded: LoadedConfiguration) -> BackupArtifact:
|
|
57
|
+
"""Compatibility wrapper returning the verified local backup artifact."""
|
|
58
|
+
return create_configured_backup_result(loaded).local
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def create_configured_backup_result(
|
|
62
|
+
loaded: LoadedConfiguration,
|
|
63
|
+
*,
|
|
64
|
+
upload: bool = False,
|
|
65
|
+
environment: Mapping[str, str] | None = None,
|
|
66
|
+
remote_storage: RemoteBackupStorage | None = None,
|
|
67
|
+
) -> ConfiguredBackupResult:
|
|
68
|
+
"""Run one exclusive, durable standalone backup operation."""
|
|
69
|
+
config = loaded.config
|
|
70
|
+
secrets = loaded.secrets
|
|
71
|
+
remote_config = config.backup.remote
|
|
72
|
+
remote_required = bool(remote_config is not None and remote_config.required)
|
|
73
|
+
should_upload = upload or remote_required
|
|
74
|
+
selected_remote = remote_storage
|
|
75
|
+
if should_upload:
|
|
76
|
+
if remote_config is None or not remote_config.enabled:
|
|
77
|
+
raise OperationFailedError(
|
|
78
|
+
"remote upload was requested but remote backup is not enabled",
|
|
79
|
+
production_changed=False,
|
|
80
|
+
previous_application_running=None,
|
|
81
|
+
next_safe_action="Enable and configure backup.remote, then retry with --upload.",
|
|
82
|
+
)
|
|
83
|
+
if selected_remote is None:
|
|
84
|
+
selected_remote = configured_s3_storage(
|
|
85
|
+
remote_config,
|
|
86
|
+
secrets=secrets,
|
|
87
|
+
environment=os.environ if environment is None else environment,
|
|
88
|
+
)
|
|
89
|
+
store = StateStore(config.state_directory, secrets=secrets)
|
|
90
|
+
lock = DeploymentLock(config.state_directory, secrets=secrets)
|
|
91
|
+
with lock:
|
|
92
|
+
if lock.previous_owner is not None and lock.previous_owner.state is LockOwnerState.ACTIVE:
|
|
93
|
+
raise RecoveryRequiredError(
|
|
94
|
+
"A prior operation left active lock-owner evidence.",
|
|
95
|
+
production_changed=False,
|
|
96
|
+
previous_application_running=None,
|
|
97
|
+
log_path=lock.owner_path,
|
|
98
|
+
next_safe_action="Run dploydb status and preserve the interrupted evidence.",
|
|
99
|
+
)
|
|
100
|
+
latest = store.latest_operation()
|
|
101
|
+
if latest is not None and latest.status in {
|
|
102
|
+
OperationStatus.IN_PROGRESS,
|
|
103
|
+
OperationStatus.RECOVERY_REQUIRED,
|
|
104
|
+
}:
|
|
105
|
+
raise RecoveryRequiredError(
|
|
106
|
+
"An unfinished or recovery-required operation blocks backup.",
|
|
107
|
+
production_changed=latest.safety.production_changed,
|
|
108
|
+
previous_application_running=latest.safety.previous_application_running,
|
|
109
|
+
log_path=store.operation_paths(latest.operation_id).events,
|
|
110
|
+
next_safe_action="Run dploydb status and resolve the recorded operation first.",
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
operation = store.create_operation(
|
|
114
|
+
operation_type="backup",
|
|
115
|
+
project=config.project,
|
|
116
|
+
configuration_fingerprint=configuration_fingerprint(config, secrets=secrets),
|
|
117
|
+
evidence={"database_path": str(config.database.path)},
|
|
118
|
+
)
|
|
119
|
+
lock.record_owner(operation_id=operation.operation_id, operation_type="backup")
|
|
120
|
+
try:
|
|
121
|
+
preflight = verify_sqlite_database(config.database.path)
|
|
122
|
+
store.transition(
|
|
123
|
+
operation.operation_id,
|
|
124
|
+
status=OperationStatus.IN_PROGRESS,
|
|
125
|
+
stage="preflight_passed",
|
|
126
|
+
message="SQLite backup preflight passed.",
|
|
127
|
+
evidence=preflight.model_dump(mode="json"),
|
|
128
|
+
)
|
|
129
|
+
artifact = create_verified_backup(
|
|
130
|
+
config.database.path,
|
|
131
|
+
project=secrets.redact_text(config.project),
|
|
132
|
+
purpose=BackupPurpose.STANDALONE,
|
|
133
|
+
storage=LocalBackupStorage(config.backup.local_directory),
|
|
134
|
+
operation_id=operation.operation_id,
|
|
135
|
+
metadata_source_path=_safe_metadata_path(config.database.path, loaded),
|
|
136
|
+
)
|
|
137
|
+
local_evidence = {
|
|
138
|
+
"backup_id": artifact.metadata.backup_id,
|
|
139
|
+
"backup_path": str(artifact.database_path),
|
|
140
|
+
"sha256": artifact.metadata.sha256,
|
|
141
|
+
"size_bytes": artifact.metadata.size_bytes,
|
|
142
|
+
}
|
|
143
|
+
remote_artifact: RemoteBackupArtifact | None = None
|
|
144
|
+
if should_upload:
|
|
145
|
+
assert selected_remote is not None
|
|
146
|
+
store.transition(
|
|
147
|
+
operation.operation_id,
|
|
148
|
+
status=OperationStatus.IN_PROGRESS,
|
|
149
|
+
stage="snapshot_verified",
|
|
150
|
+
message="Verified local SQLite backup completed before remote upload.",
|
|
151
|
+
evidence=local_evidence,
|
|
152
|
+
)
|
|
153
|
+
remote_artifact = selected_remote.put(artifact)
|
|
154
|
+
store.transition(
|
|
155
|
+
operation.operation_id,
|
|
156
|
+
status=OperationStatus.SUCCEEDED,
|
|
157
|
+
stage="remote_snapshot_verified",
|
|
158
|
+
message="Verified local and remote SQLite backup completed.",
|
|
159
|
+
evidence={
|
|
160
|
+
**local_evidence,
|
|
161
|
+
"remote": remote_artifact.model_dump(mode="json"),
|
|
162
|
+
"remote_required": remote_required,
|
|
163
|
+
},
|
|
164
|
+
)
|
|
165
|
+
else:
|
|
166
|
+
store.transition(
|
|
167
|
+
operation.operation_id,
|
|
168
|
+
status=OperationStatus.SUCCEEDED,
|
|
169
|
+
stage="snapshot_verified",
|
|
170
|
+
message="Verified local SQLite backup completed.",
|
|
171
|
+
evidence=local_evidence,
|
|
172
|
+
)
|
|
173
|
+
return ConfiguredBackupResult(local=artifact, remote=remote_artifact)
|
|
174
|
+
except DployDBError as error:
|
|
175
|
+
status = (
|
|
176
|
+
OperationStatus.RECOVERY_REQUIRED
|
|
177
|
+
if error.payload.recovery_required
|
|
178
|
+
else OperationStatus.FAILED_SAFE
|
|
179
|
+
)
|
|
180
|
+
store.transition(
|
|
181
|
+
operation.operation_id,
|
|
182
|
+
status=status,
|
|
183
|
+
stage=(
|
|
184
|
+
"recovery_required"
|
|
185
|
+
if status is OperationStatus.RECOVERY_REQUIRED
|
|
186
|
+
else "failed_safe"
|
|
187
|
+
),
|
|
188
|
+
message="Backup operation did not complete.",
|
|
189
|
+
safety=SafetyFacts(
|
|
190
|
+
production_changed=error.payload.production_changed,
|
|
191
|
+
previous_application_running=error.payload.previous_application_running,
|
|
192
|
+
recovery_required=error.payload.recovery_required,
|
|
193
|
+
),
|
|
194
|
+
failure=FailureRecord(
|
|
195
|
+
error_code=error.payload.error_code,
|
|
196
|
+
what_failed=error.payload.what_failed,
|
|
197
|
+
log_path=error.payload.log_path,
|
|
198
|
+
next_safe_action=error.payload.next_safe_action,
|
|
199
|
+
),
|
|
200
|
+
)
|
|
201
|
+
raise
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def verify_configured_backup(
|
|
205
|
+
loaded: LoadedConfiguration,
|
|
206
|
+
backup_id: str,
|
|
207
|
+
) -> BackupArtifact:
|
|
208
|
+
"""Read-only verification constrained to the configured project and storage."""
|
|
209
|
+
storage = LocalBackupStorage(loaded.config.backup.local_directory)
|
|
210
|
+
artifact = verify_backup(storage, backup_id)
|
|
211
|
+
if artifact.metadata.project != loaded.secrets.redact_text(loaded.config.project):
|
|
212
|
+
raise _verification_error(
|
|
213
|
+
artifact.metadata_path,
|
|
214
|
+
"backup project does not match the configured project",
|
|
215
|
+
)
|
|
216
|
+
return artifact
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
@contextmanager
|
|
220
|
+
def open_verified_configured_backup(
|
|
221
|
+
loaded: LoadedConfiguration,
|
|
222
|
+
backup_id: str,
|
|
223
|
+
*,
|
|
224
|
+
environment: Mapping[str, str] | None = None,
|
|
225
|
+
remote_storage: RemoteBackupStorage | None = None,
|
|
226
|
+
) -> Iterator[BackupArtifact]:
|
|
227
|
+
"""Yield a verified local backup or an ephemeral verified remote hydration."""
|
|
228
|
+
local = LocalBackupStorage(loaded.config.backup.local_directory)
|
|
229
|
+
try:
|
|
230
|
+
artifact = verify_configured_backup(loaded, backup_id)
|
|
231
|
+
except SafetyCheckError:
|
|
232
|
+
if not _local_artifact_absent(local, backup_id):
|
|
233
|
+
raise
|
|
234
|
+
else:
|
|
235
|
+
yield artifact
|
|
236
|
+
return
|
|
237
|
+
|
|
238
|
+
remote_config = loaded.config.backup.remote
|
|
239
|
+
if remote_config is None or not remote_config.enabled:
|
|
240
|
+
raise _verification_error(
|
|
241
|
+
local.root / f"{backup_id}.json",
|
|
242
|
+
"backup is absent locally and remote backup is not enabled",
|
|
243
|
+
)
|
|
244
|
+
selected_remote = remote_storage
|
|
245
|
+
if selected_remote is None:
|
|
246
|
+
selected_remote = configured_s3_storage(
|
|
247
|
+
remote_config,
|
|
248
|
+
secrets=loaded.secrets,
|
|
249
|
+
environment=os.environ if environment is None else environment,
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
with tempfile.TemporaryDirectory(prefix="dploydb-remote-hydration-") as directory:
|
|
253
|
+
root = Path(directory)
|
|
254
|
+
os.chmod(root, 0o700)
|
|
255
|
+
temporary = LocalBackupStorage(root)
|
|
256
|
+
staged = temporary.create_staging_database(backup_id)
|
|
257
|
+
try:
|
|
258
|
+
remote = selected_remote.download(backup_id, staged)
|
|
259
|
+
if remote.metadata.backup.project != loaded.secrets.redact_text(loaded.config.project):
|
|
260
|
+
raise _verification_error(
|
|
261
|
+
staged,
|
|
262
|
+
"remote backup project does not match the configured project",
|
|
263
|
+
)
|
|
264
|
+
artifact = temporary.put(staged, remote.metadata.backup)
|
|
265
|
+
yield verify_backup(temporary, backup_id)
|
|
266
|
+
finally:
|
|
267
|
+
staged.unlink(missing_ok=True)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _local_artifact_absent(storage: LocalBackupStorage, backup_id: str) -> bool:
|
|
271
|
+
database = storage.root / f"{backup_id}.db"
|
|
272
|
+
metadata = storage.root / f"{backup_id}.json"
|
|
273
|
+
return not any(path.exists() or path.is_symlink() for path in (database, metadata))
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _safe_metadata_path(path: Path, loaded: LoadedConfiguration) -> Path:
|
|
277
|
+
safe = Path(loaded.secrets.redact_text(str(path)))
|
|
278
|
+
return safe if safe.is_absolute() else Path("/[REDACTED]")
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def create_verified_backup(
|
|
282
|
+
source: Path,
|
|
283
|
+
*,
|
|
284
|
+
project: str,
|
|
285
|
+
purpose: BackupPurpose,
|
|
286
|
+
storage: BackupStorage,
|
|
287
|
+
operation_id: str | None = None,
|
|
288
|
+
metadata_source_path: Path | None = None,
|
|
289
|
+
timeout_seconds: float = BACKUP_TIMEOUT_SECONDS,
|
|
290
|
+
) -> BackupArtifact:
|
|
291
|
+
"""Create, verify, checksum, and commit one live SQLite snapshot."""
|
|
292
|
+
if timeout_seconds <= 0:
|
|
293
|
+
raise ValueError("backup timeout must be positive")
|
|
294
|
+
created_at = utc_now()
|
|
295
|
+
backup_id = new_backup_id()
|
|
296
|
+
staged = storage.create_staging_database(backup_id)
|
|
297
|
+
try:
|
|
298
|
+
verify_sqlite_database(source, timeout_seconds=min(timeout_seconds, 10.0))
|
|
299
|
+
_online_snapshot(source, staged, timeout_seconds=timeout_seconds)
|
|
300
|
+
_fsync_file(staged)
|
|
301
|
+
sqlite_evidence = verify_sqlite_database(
|
|
302
|
+
staged,
|
|
303
|
+
timeout_seconds=min(timeout_seconds, DEFAULT_SQLITE_TIMEOUT_SECONDS),
|
|
304
|
+
)
|
|
305
|
+
size_bytes, sha256 = calculate_sha256(staged)
|
|
306
|
+
metadata = BackupMetadata(
|
|
307
|
+
backup_id=backup_id,
|
|
308
|
+
project=project,
|
|
309
|
+
purpose=purpose,
|
|
310
|
+
source_database_path=metadata_source_path or source,
|
|
311
|
+
database_file_name=f"{backup_id}.db",
|
|
312
|
+
size_bytes=size_bytes,
|
|
313
|
+
sha256=sha256,
|
|
314
|
+
sqlite=sqlite_evidence,
|
|
315
|
+
operation_id=operation_id,
|
|
316
|
+
created_at=created_at,
|
|
317
|
+
completed_at=utc_now(),
|
|
318
|
+
)
|
|
319
|
+
storage.put(staged, metadata)
|
|
320
|
+
return verify_backup(storage, backup_id)
|
|
321
|
+
except DployDBError as exc:
|
|
322
|
+
_cleanup_staging(staged, exc)
|
|
323
|
+
raise
|
|
324
|
+
except (OSError, sqlite3.Error, TimeoutError) as exc:
|
|
325
|
+
error = _backup_error(source, f"verified SQLite backup could not be created: {exc}")
|
|
326
|
+
_cleanup_staging(staged, error)
|
|
327
|
+
raise error from None
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def verify_backup(storage: BackupStorage, backup_id: str) -> BackupArtifact:
|
|
331
|
+
"""Revalidate committed metadata, bytes, checksum, and SQLite contents."""
|
|
332
|
+
artifact = storage.get(backup_id)
|
|
333
|
+
size_bytes, sha256 = calculate_sha256(artifact.database_path)
|
|
334
|
+
if size_bytes != artifact.metadata.size_bytes:
|
|
335
|
+
raise _verification_error(
|
|
336
|
+
artifact.database_path,
|
|
337
|
+
f"backup size mismatch: expected {artifact.metadata.size_bytes}, found {size_bytes}",
|
|
338
|
+
)
|
|
339
|
+
if sha256 != artifact.metadata.sha256:
|
|
340
|
+
raise _verification_error(artifact.database_path, "backup SHA-256 checksum mismatch")
|
|
341
|
+
verify_sqlite_database(artifact.database_path)
|
|
342
|
+
final_size, final_sha256 = calculate_sha256(artifact.database_path)
|
|
343
|
+
if final_size != size_bytes or final_sha256 != sha256:
|
|
344
|
+
raise _verification_error(
|
|
345
|
+
artifact.database_path,
|
|
346
|
+
"backup changed while its SQLite contents were verified",
|
|
347
|
+
)
|
|
348
|
+
return artifact
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def calculate_sha256(path: Path) -> tuple[int, str]:
|
|
352
|
+
"""Hash one stable regular non-symlink file and reject concurrent mutation."""
|
|
353
|
+
deadline = time.monotonic() + BACKUP_TIMEOUT_SECONDS
|
|
354
|
+
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
355
|
+
descriptor = -1
|
|
356
|
+
try:
|
|
357
|
+
descriptor = os.open(path, flags)
|
|
358
|
+
before = os.fstat(descriptor)
|
|
359
|
+
if not stat.S_ISREG(before.st_mode):
|
|
360
|
+
raise OSError("checksum target is not a regular file")
|
|
361
|
+
digest = hashlib.sha256()
|
|
362
|
+
size = 0
|
|
363
|
+
while True:
|
|
364
|
+
if time.monotonic() >= deadline:
|
|
365
|
+
raise OSError(f"checksum timed out after {BACKUP_TIMEOUT_SECONDS:g} seconds")
|
|
366
|
+
chunk = os.read(descriptor, HASH_CHUNK_BYTES)
|
|
367
|
+
if not chunk:
|
|
368
|
+
break
|
|
369
|
+
digest.update(chunk)
|
|
370
|
+
size += len(chunk)
|
|
371
|
+
after = os.fstat(descriptor)
|
|
372
|
+
except OSError as exc:
|
|
373
|
+
raise _verification_error(path, f"backup checksum could not be calculated: {exc}") from None
|
|
374
|
+
finally:
|
|
375
|
+
if descriptor >= 0:
|
|
376
|
+
os.close(descriptor)
|
|
377
|
+
if (before.st_dev, before.st_ino, before.st_size, before.st_mtime_ns) != (
|
|
378
|
+
after.st_dev,
|
|
379
|
+
after.st_ino,
|
|
380
|
+
after.st_size,
|
|
381
|
+
after.st_mtime_ns,
|
|
382
|
+
) or size != after.st_size:
|
|
383
|
+
raise _verification_error(path, "backup changed while its checksum was calculated")
|
|
384
|
+
return size, digest.hexdigest()
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def _online_snapshot(source: Path, destination: Path, *, timeout_seconds: float) -> None:
|
|
388
|
+
deadline = time.monotonic() + timeout_seconds
|
|
389
|
+
source_connection: sqlite3.Connection | None = None
|
|
390
|
+
destination_connection: sqlite3.Connection | None = None
|
|
391
|
+
|
|
392
|
+
def progress(_status: int, _remaining: int, _total: int) -> None:
|
|
393
|
+
if time.monotonic() >= deadline:
|
|
394
|
+
raise TimeoutError(f"online backup timed out after {timeout_seconds:g} seconds")
|
|
395
|
+
|
|
396
|
+
try:
|
|
397
|
+
source_connection = sqlite3.connect(
|
|
398
|
+
f"{source.as_uri()}?mode=ro",
|
|
399
|
+
uri=True,
|
|
400
|
+
timeout=timeout_seconds,
|
|
401
|
+
isolation_level=None,
|
|
402
|
+
)
|
|
403
|
+
destination_connection = sqlite3.connect(
|
|
404
|
+
destination,
|
|
405
|
+
timeout=timeout_seconds,
|
|
406
|
+
isolation_level=None,
|
|
407
|
+
)
|
|
408
|
+
source_connection.backup(
|
|
409
|
+
destination_connection,
|
|
410
|
+
pages=COPY_PAGES,
|
|
411
|
+
progress=progress,
|
|
412
|
+
sleep=0.05,
|
|
413
|
+
)
|
|
414
|
+
finally:
|
|
415
|
+
if destination_connection is not None:
|
|
416
|
+
destination_connection.close()
|
|
417
|
+
if source_connection is not None:
|
|
418
|
+
source_connection.close()
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _fsync_file(path: Path) -> None:
|
|
422
|
+
descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_CLOEXEC", 0))
|
|
423
|
+
try:
|
|
424
|
+
os.fsync(descriptor)
|
|
425
|
+
finally:
|
|
426
|
+
os.close(descriptor)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _cleanup_staging(path: Path, error: BaseException) -> None:
|
|
430
|
+
if not path.exists():
|
|
431
|
+
return
|
|
432
|
+
try:
|
|
433
|
+
path.unlink()
|
|
434
|
+
except OSError as cleanup_error:
|
|
435
|
+
error.add_note(f"Backup staging cleanup also failed: {cleanup_error}")
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _backup_error(path: Path, detail: str) -> OperationFailedError:
|
|
439
|
+
return OperationFailedError(
|
|
440
|
+
detail,
|
|
441
|
+
production_changed=False,
|
|
442
|
+
previous_application_running=None,
|
|
443
|
+
log_path=path,
|
|
444
|
+
next_safe_action="Production was not changed; correct the failure and create a new backup.",
|
|
445
|
+
)
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _verification_error(path: Path, detail: str) -> SafetyCheckError:
|
|
449
|
+
return SafetyCheckError(
|
|
450
|
+
detail,
|
|
451
|
+
production_changed=False,
|
|
452
|
+
previous_application_running=None,
|
|
453
|
+
log_path=path,
|
|
454
|
+
next_safe_action="Do not restore this backup; use another verified backup or create one.",
|
|
455
|
+
)
|