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 ADDED
@@ -0,0 +1 @@
1
+ """DployDB package."""
dploydb/__main__.py ADDED
@@ -0,0 +1,6 @@
1
+ """Run DployDB with ``python -m dploydb``."""
2
+
3
+ from dploydb.cli import app
4
+
5
+ if __name__ == "__main__":
6
+ app()
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
+ )