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/restore.py
ADDED
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
"""Internal stopped-application restore with verified pre-restore recovery."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import stat
|
|
7
|
+
import time
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Final
|
|
11
|
+
from uuid import uuid4
|
|
12
|
+
|
|
13
|
+
from dploydb.backup import (
|
|
14
|
+
calculate_sha256,
|
|
15
|
+
create_verified_backup,
|
|
16
|
+
verify_configured_backup,
|
|
17
|
+
)
|
|
18
|
+
from dploydb.config import LoadedConfiguration, configuration_fingerprint
|
|
19
|
+
from dploydb.errors import (
|
|
20
|
+
DployDBError,
|
|
21
|
+
OperationFailedError,
|
|
22
|
+
RecoveryRequiredError,
|
|
23
|
+
SafetyCheckError,
|
|
24
|
+
)
|
|
25
|
+
from dploydb.locking import DeploymentLock
|
|
26
|
+
from dploydb.models import (
|
|
27
|
+
BackupArtifact,
|
|
28
|
+
BackupPurpose,
|
|
29
|
+
FailureRecord,
|
|
30
|
+
LockOwnerState,
|
|
31
|
+
OperationStatus,
|
|
32
|
+
RestoreResult,
|
|
33
|
+
SafetyFacts,
|
|
34
|
+
VerifiedDatabaseRestoreResult,
|
|
35
|
+
utc_now,
|
|
36
|
+
)
|
|
37
|
+
from dploydb.redaction import SecretRegistry
|
|
38
|
+
from dploydb.sqlite_checks import verify_sqlite_database
|
|
39
|
+
from dploydb.state import StateStore
|
|
40
|
+
from dploydb.storage.local import LocalBackupStorage
|
|
41
|
+
|
|
42
|
+
RESTORE_TIMEOUT_SECONDS: Final[float] = 120.0
|
|
43
|
+
COPY_CHUNK_BYTES: Final[int] = 1024 * 1024
|
|
44
|
+
FILE_MODE: Final[int] = 0o600
|
|
45
|
+
FaultInjector = Callable[[str], None]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def restore_verified_database(
|
|
49
|
+
artifact: BackupArtifact,
|
|
50
|
+
target: Path,
|
|
51
|
+
*,
|
|
52
|
+
application_stopped: bool,
|
|
53
|
+
traffic_activated: bool,
|
|
54
|
+
secrets: SecretRegistry,
|
|
55
|
+
fault_injector: FaultInjector | None = None,
|
|
56
|
+
) -> VerifiedDatabaseRestoreResult:
|
|
57
|
+
"""Restore one verified backup inside an existing locked pre-traffic cutover."""
|
|
58
|
+
if traffic_activated:
|
|
59
|
+
raise SafetyCheckError(
|
|
60
|
+
"automatic database restore is forbidden after new traffic was activated",
|
|
61
|
+
production_changed=True,
|
|
62
|
+
previous_application_running=False,
|
|
63
|
+
log_path=target,
|
|
64
|
+
next_safe_action=(
|
|
65
|
+
"Keep the current database and use the confirmed manual restore workflow."
|
|
66
|
+
),
|
|
67
|
+
)
|
|
68
|
+
if not application_stopped:
|
|
69
|
+
raise SafetyCheckError(
|
|
70
|
+
"automatic database restore requires every managed database user to be stopped",
|
|
71
|
+
production_changed=True,
|
|
72
|
+
previous_application_running=None,
|
|
73
|
+
log_path=target,
|
|
74
|
+
next_safe_action="Stop and prove every database user before retrying rollback.",
|
|
75
|
+
)
|
|
76
|
+
if not target.is_absolute() or target == artifact.database_path:
|
|
77
|
+
raise ValueError("restore target must be an absolute production path distinct from backup")
|
|
78
|
+
|
|
79
|
+
inject = fault_injector or _no_fault
|
|
80
|
+
staged: Path | None = None
|
|
81
|
+
replacement_started = False
|
|
82
|
+
try:
|
|
83
|
+
_verify_materialized(artifact.database_path, artifact)
|
|
84
|
+
staged = _materialize_backup(artifact, target.parent)
|
|
85
|
+
_verify_materialized(staged, artifact)
|
|
86
|
+
inject("rollback_after_staging")
|
|
87
|
+
_remove_sqlite_sidecars(target)
|
|
88
|
+
os.replace(staged, target)
|
|
89
|
+
staged = None
|
|
90
|
+
replacement_started = True
|
|
91
|
+
os.chmod(target, FILE_MODE, follow_symlinks=False)
|
|
92
|
+
_fsync_directory(target.parent)
|
|
93
|
+
inject("rollback_after_replace")
|
|
94
|
+
_verify_materialized(target, artifact)
|
|
95
|
+
size_bytes, sha256 = calculate_sha256(target)
|
|
96
|
+
sqlite = verify_sqlite_database(target)
|
|
97
|
+
return VerifiedDatabaseRestoreResult(
|
|
98
|
+
backup_id=artifact.metadata.backup_id,
|
|
99
|
+
database_path=target,
|
|
100
|
+
size_bytes=size_bytes,
|
|
101
|
+
sha256=sha256,
|
|
102
|
+
sqlite=sqlite,
|
|
103
|
+
restored_at=utc_now(),
|
|
104
|
+
)
|
|
105
|
+
except Exception as raw_error:
|
|
106
|
+
if staged is not None:
|
|
107
|
+
try:
|
|
108
|
+
staged.unlink(missing_ok=True)
|
|
109
|
+
except OSError as cleanup_error:
|
|
110
|
+
raw_error.add_note(
|
|
111
|
+
secrets.redact_text(f"Rollback staging cleanup also failed: {cleanup_error}")
|
|
112
|
+
)
|
|
113
|
+
detail = secrets.redact_text(f"{type(raw_error).__name__}: {raw_error}")
|
|
114
|
+
if replacement_started:
|
|
115
|
+
raise RecoveryRequiredError(
|
|
116
|
+
"verified database rollback started replacement but could not be proven: " + detail,
|
|
117
|
+
production_changed=True,
|
|
118
|
+
previous_application_running=False,
|
|
119
|
+
log_path=target,
|
|
120
|
+
next_safe_action=(
|
|
121
|
+
"Keep every application stopped and verify or restore the recorded final "
|
|
122
|
+
"backup manually."
|
|
123
|
+
),
|
|
124
|
+
) from None
|
|
125
|
+
raise OperationFailedError(
|
|
126
|
+
"verified database rollback failed before replacement: " + detail,
|
|
127
|
+
production_changed=True,
|
|
128
|
+
previous_application_running=False,
|
|
129
|
+
log_path=target,
|
|
130
|
+
next_safe_action=(
|
|
131
|
+
"Keep every application stopped, reverify the final backup, and retry rollback."
|
|
132
|
+
),
|
|
133
|
+
) from None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def restore_stopped_database(
|
|
137
|
+
loaded: LoadedConfiguration,
|
|
138
|
+
backup_id: str,
|
|
139
|
+
*,
|
|
140
|
+
application_stopped: bool,
|
|
141
|
+
fault_injector: FaultInjector | None = None,
|
|
142
|
+
) -> RestoreResult:
|
|
143
|
+
"""Restore a backup only after the caller has stopped every database user."""
|
|
144
|
+
if not application_stopped:
|
|
145
|
+
raise SafetyCheckError(
|
|
146
|
+
"restore requires the application and all database users to be stopped",
|
|
147
|
+
production_changed=False,
|
|
148
|
+
previous_application_running=None,
|
|
149
|
+
log_path=loaded.config.database.path,
|
|
150
|
+
next_safe_action="Stop every database user, then invoke the controlled restore flow.",
|
|
151
|
+
)
|
|
152
|
+
|
|
153
|
+
config = loaded.config
|
|
154
|
+
secrets = loaded.secrets
|
|
155
|
+
target = config.database.path
|
|
156
|
+
storage = LocalBackupStorage(config.backup.local_directory)
|
|
157
|
+
store = StateStore(config.state_directory, secrets=secrets)
|
|
158
|
+
lock = DeploymentLock(config.state_directory, secrets=secrets)
|
|
159
|
+
inject = fault_injector or _no_fault
|
|
160
|
+
|
|
161
|
+
with lock:
|
|
162
|
+
_require_clean_operation_state(lock, store)
|
|
163
|
+
operation = store.create_operation(
|
|
164
|
+
operation_type="restore",
|
|
165
|
+
project=config.project,
|
|
166
|
+
configuration_fingerprint=configuration_fingerprint(config, secrets=secrets),
|
|
167
|
+
evidence={"selected_backup_id": backup_id, "database_path": str(target)},
|
|
168
|
+
)
|
|
169
|
+
lock.record_owner(operation_id=operation.operation_id, operation_type="restore")
|
|
170
|
+
|
|
171
|
+
staged: Path | None = None
|
|
172
|
+
pre_restore: BackupArtifact | None = None
|
|
173
|
+
production_mutation_started = False
|
|
174
|
+
try:
|
|
175
|
+
selected = verify_configured_backup(loaded, backup_id)
|
|
176
|
+
pre_restore = create_verified_backup(
|
|
177
|
+
target,
|
|
178
|
+
project=secrets.redact_text(config.project),
|
|
179
|
+
purpose=BackupPurpose.PRE_RESTORE,
|
|
180
|
+
storage=storage,
|
|
181
|
+
operation_id=operation.operation_id,
|
|
182
|
+
metadata_source_path=_safe_metadata_path(target, loaded),
|
|
183
|
+
)
|
|
184
|
+
staged = _materialize_backup(selected, target.parent)
|
|
185
|
+
_verify_materialized(staged, selected)
|
|
186
|
+
inject("after_staging")
|
|
187
|
+
store.transition(
|
|
188
|
+
operation.operation_id,
|
|
189
|
+
status=OperationStatus.IN_PROGRESS,
|
|
190
|
+
stage="restore_prepared",
|
|
191
|
+
message="Selected and pre-restore backups are verified.",
|
|
192
|
+
evidence={
|
|
193
|
+
"selected_backup_id": selected.metadata.backup_id,
|
|
194
|
+
"pre_restore_backup_id": pre_restore.metadata.backup_id,
|
|
195
|
+
},
|
|
196
|
+
)
|
|
197
|
+
store.transition(
|
|
198
|
+
operation.operation_id,
|
|
199
|
+
status=OperationStatus.IN_PROGRESS,
|
|
200
|
+
stage="manual_restore_started",
|
|
201
|
+
message="Production database replacement is starting with all users stopped.",
|
|
202
|
+
safety=SafetyFacts(
|
|
203
|
+
production_changed=True,
|
|
204
|
+
previous_application_running=False,
|
|
205
|
+
recovery_required=False,
|
|
206
|
+
),
|
|
207
|
+
)
|
|
208
|
+
production_mutation_started = True
|
|
209
|
+
_remove_sqlite_sidecars(target)
|
|
210
|
+
os.replace(staged, target)
|
|
211
|
+
staged = None
|
|
212
|
+
os.chmod(target, FILE_MODE, follow_symlinks=False)
|
|
213
|
+
_fsync_directory(target.parent)
|
|
214
|
+
inject("after_replace")
|
|
215
|
+
_verify_materialized(target, selected)
|
|
216
|
+
selected_size, selected_sha256 = calculate_sha256(target)
|
|
217
|
+
if selected_size <= 0:
|
|
218
|
+
raise AssertionError("a verified restored database cannot be empty")
|
|
219
|
+
result = RestoreResult(
|
|
220
|
+
selected_backup_id=backup_id,
|
|
221
|
+
pre_restore_backup_id=pre_restore.metadata.backup_id,
|
|
222
|
+
database_path=target,
|
|
223
|
+
sha256=selected_sha256,
|
|
224
|
+
restored_at=utc_now(),
|
|
225
|
+
)
|
|
226
|
+
store.transition(
|
|
227
|
+
operation.operation_id,
|
|
228
|
+
status=OperationStatus.SUCCEEDED,
|
|
229
|
+
stage="manual_restore_completed",
|
|
230
|
+
message="Selected backup restored and verified with the application stopped.",
|
|
231
|
+
evidence=result.model_dump(mode="json"),
|
|
232
|
+
safety=SafetyFacts(
|
|
233
|
+
production_changed=True,
|
|
234
|
+
previous_application_running=False,
|
|
235
|
+
recovery_required=False,
|
|
236
|
+
),
|
|
237
|
+
)
|
|
238
|
+
return result
|
|
239
|
+
except Exception as raw_error:
|
|
240
|
+
_cleanup_temporary(staged, raw_error)
|
|
241
|
+
error = _normalize_restore_error(raw_error, target)
|
|
242
|
+
if production_mutation_started and pre_restore is not None:
|
|
243
|
+
try:
|
|
244
|
+
_restore_previous_database(
|
|
245
|
+
target,
|
|
246
|
+
pre_restore,
|
|
247
|
+
fault_injector=inject,
|
|
248
|
+
)
|
|
249
|
+
except Exception as rollback_error:
|
|
250
|
+
recovery = RecoveryRequiredError(
|
|
251
|
+
"Restore failed and the pre-restore database could not be proven restored: "
|
|
252
|
+
f"{rollback_error}",
|
|
253
|
+
production_changed=True,
|
|
254
|
+
previous_application_running=False,
|
|
255
|
+
log_path=store.operation_paths(operation.operation_id).events,
|
|
256
|
+
next_safe_action=(
|
|
257
|
+
"Keep the application stopped. Preserve all backup and operation "
|
|
258
|
+
"evidence, then restore the recorded pre-restore backup manually."
|
|
259
|
+
),
|
|
260
|
+
)
|
|
261
|
+
_finish_failure(store, operation.operation_id, recovery)
|
|
262
|
+
raise recovery from None
|
|
263
|
+
error = OperationFailedError(
|
|
264
|
+
f"Restore did not complete; the previous database was restored and verified: "
|
|
265
|
+
f"{error.payload.what_failed}",
|
|
266
|
+
production_changed=True,
|
|
267
|
+
previous_application_running=False,
|
|
268
|
+
log_path=store.operation_paths(operation.operation_id).events,
|
|
269
|
+
next_safe_action=(
|
|
270
|
+
"Keep the application stopped, inspect the restore evidence, and retry "
|
|
271
|
+
"only after correcting the original failure."
|
|
272
|
+
),
|
|
273
|
+
)
|
|
274
|
+
_finish_failure(store, operation.operation_id, error)
|
|
275
|
+
raise error from None
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _safe_metadata_path(path: Path, loaded: LoadedConfiguration) -> Path:
|
|
279
|
+
safe = Path(loaded.secrets.redact_text(str(path)))
|
|
280
|
+
return safe if safe.is_absolute() else Path("/[REDACTED]")
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _require_clean_operation_state(lock: DeploymentLock, store: StateStore) -> None:
|
|
284
|
+
if lock.previous_owner is not None and lock.previous_owner.state is LockOwnerState.ACTIVE:
|
|
285
|
+
raise RecoveryRequiredError(
|
|
286
|
+
"A prior operation left active lock-owner evidence.",
|
|
287
|
+
production_changed=False,
|
|
288
|
+
previous_application_running=False,
|
|
289
|
+
log_path=lock.owner_path,
|
|
290
|
+
next_safe_action="Run dploydb status and preserve the interrupted evidence.",
|
|
291
|
+
)
|
|
292
|
+
latest = store.latest_operation()
|
|
293
|
+
if latest is not None and latest.status in {
|
|
294
|
+
OperationStatus.IN_PROGRESS,
|
|
295
|
+
OperationStatus.RECOVERY_REQUIRED,
|
|
296
|
+
}:
|
|
297
|
+
raise RecoveryRequiredError(
|
|
298
|
+
"An unfinished or recovery-required operation blocks restore.",
|
|
299
|
+
production_changed=latest.safety.production_changed,
|
|
300
|
+
previous_application_running=latest.safety.previous_application_running,
|
|
301
|
+
log_path=store.operation_paths(latest.operation_id).events,
|
|
302
|
+
next_safe_action="Run dploydb status and resolve the recorded operation first.",
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
def _materialize_backup(artifact: BackupArtifact, destination: Path) -> Path:
|
|
307
|
+
if destination.is_symlink():
|
|
308
|
+
raise OSError("database directory must not be a symlink")
|
|
309
|
+
details = destination.stat()
|
|
310
|
+
if not stat.S_ISDIR(details.st_mode):
|
|
311
|
+
raise OSError("database directory is not a directory")
|
|
312
|
+
path = destination / f".dploydb-restore-{uuid4().hex}.tmp"
|
|
313
|
+
source_descriptor = -1
|
|
314
|
+
destination_descriptor = -1
|
|
315
|
+
deadline = time.monotonic() + RESTORE_TIMEOUT_SECONDS
|
|
316
|
+
flags = os.O_CREAT | os.O_EXCL | os.O_WRONLY
|
|
317
|
+
flags |= getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
318
|
+
try:
|
|
319
|
+
source_descriptor = os.open(
|
|
320
|
+
artifact.database_path,
|
|
321
|
+
os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0),
|
|
322
|
+
)
|
|
323
|
+
destination_descriptor = os.open(path, flags, FILE_MODE)
|
|
324
|
+
os.fchmod(destination_descriptor, FILE_MODE)
|
|
325
|
+
while True:
|
|
326
|
+
if time.monotonic() >= deadline:
|
|
327
|
+
raise TimeoutError(
|
|
328
|
+
f"restore staging timed out after {RESTORE_TIMEOUT_SECONDS:g} seconds"
|
|
329
|
+
)
|
|
330
|
+
chunk = os.read(source_descriptor, COPY_CHUNK_BYTES)
|
|
331
|
+
if not chunk:
|
|
332
|
+
break
|
|
333
|
+
_write_all(destination_descriptor, chunk)
|
|
334
|
+
os.fsync(destination_descriptor)
|
|
335
|
+
except Exception:
|
|
336
|
+
try:
|
|
337
|
+
path.unlink(missing_ok=True)
|
|
338
|
+
except OSError:
|
|
339
|
+
pass
|
|
340
|
+
raise
|
|
341
|
+
finally:
|
|
342
|
+
if destination_descriptor >= 0:
|
|
343
|
+
os.close(destination_descriptor)
|
|
344
|
+
if source_descriptor >= 0:
|
|
345
|
+
os.close(source_descriptor)
|
|
346
|
+
_fsync_directory(destination)
|
|
347
|
+
return path
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _verify_materialized(path: Path, artifact: BackupArtifact) -> None:
|
|
351
|
+
size_bytes, sha256 = calculate_sha256(path)
|
|
352
|
+
if size_bytes != artifact.metadata.size_bytes or sha256 != artifact.metadata.sha256:
|
|
353
|
+
raise SafetyCheckError(
|
|
354
|
+
"materialized restore file does not match selected backup metadata",
|
|
355
|
+
production_changed=False,
|
|
356
|
+
previous_application_running=False,
|
|
357
|
+
log_path=path,
|
|
358
|
+
next_safe_action="Do not activate this restore file; preserve the verified backup.",
|
|
359
|
+
)
|
|
360
|
+
verify_sqlite_database(path)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _restore_previous_database(
|
|
364
|
+
target: Path,
|
|
365
|
+
pre_restore: BackupArtifact,
|
|
366
|
+
*,
|
|
367
|
+
fault_injector: FaultInjector,
|
|
368
|
+
) -> None:
|
|
369
|
+
rollback_staged: Path | None = _materialize_backup(pre_restore, target.parent)
|
|
370
|
+
try:
|
|
371
|
+
assert rollback_staged is not None
|
|
372
|
+
_verify_materialized(rollback_staged, pre_restore)
|
|
373
|
+
fault_injector("rollback_before_replace")
|
|
374
|
+
_remove_sqlite_sidecars(target)
|
|
375
|
+
os.replace(rollback_staged, target)
|
|
376
|
+
rollback_staged = None
|
|
377
|
+
os.chmod(target, FILE_MODE, follow_symlinks=False)
|
|
378
|
+
_fsync_directory(target.parent)
|
|
379
|
+
_verify_materialized(target, pre_restore)
|
|
380
|
+
finally:
|
|
381
|
+
if rollback_staged is not None:
|
|
382
|
+
rollback_staged.unlink(missing_ok=True)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _remove_sqlite_sidecars(target: Path) -> None:
|
|
386
|
+
for suffix in ("-wal", "-shm"):
|
|
387
|
+
sidecar = Path(f"{target}{suffix}")
|
|
388
|
+
if not sidecar.exists() and not sidecar.is_symlink():
|
|
389
|
+
continue
|
|
390
|
+
details = sidecar.lstat()
|
|
391
|
+
if sidecar.is_symlink() or not stat.S_ISREG(details.st_mode):
|
|
392
|
+
raise OSError(f"refusing unsafe SQLite sidecar: {sidecar}")
|
|
393
|
+
sidecar.unlink()
|
|
394
|
+
_fsync_directory(target.parent)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _finish_failure(store: StateStore, operation_id: str, error: DployDBError) -> None:
|
|
398
|
+
status = (
|
|
399
|
+
OperationStatus.RECOVERY_REQUIRED
|
|
400
|
+
if error.payload.recovery_required
|
|
401
|
+
else OperationStatus.FAILED_SAFE
|
|
402
|
+
)
|
|
403
|
+
store.transition(
|
|
404
|
+
operation_id,
|
|
405
|
+
status=status,
|
|
406
|
+
stage=status.value,
|
|
407
|
+
message="Restore operation did not complete.",
|
|
408
|
+
safety=SafetyFacts(
|
|
409
|
+
production_changed=error.payload.production_changed,
|
|
410
|
+
previous_application_running=error.payload.previous_application_running,
|
|
411
|
+
recovery_required=error.payload.recovery_required,
|
|
412
|
+
),
|
|
413
|
+
failure=FailureRecord(
|
|
414
|
+
error_code=error.payload.error_code,
|
|
415
|
+
what_failed=error.payload.what_failed,
|
|
416
|
+
log_path=error.payload.log_path,
|
|
417
|
+
next_safe_action=error.payload.next_safe_action,
|
|
418
|
+
),
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _normalize_restore_error(error: Exception, target: Path) -> DployDBError:
|
|
423
|
+
if isinstance(error, DployDBError):
|
|
424
|
+
return error
|
|
425
|
+
return OperationFailedError(
|
|
426
|
+
f"stopped-application restore failed: {error}",
|
|
427
|
+
production_changed=False,
|
|
428
|
+
previous_application_running=False,
|
|
429
|
+
log_path=target,
|
|
430
|
+
next_safe_action="Production was not replaced; correct the restore failure and retry.",
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _cleanup_temporary(path: Path | None, error: BaseException) -> None:
|
|
435
|
+
if path is None or not path.exists():
|
|
436
|
+
return
|
|
437
|
+
try:
|
|
438
|
+
path.unlink()
|
|
439
|
+
except OSError as cleanup_error:
|
|
440
|
+
error.add_note(f"Restore staging cleanup also failed: {cleanup_error}")
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _write_all(descriptor: int, payload: bytes) -> None:
|
|
444
|
+
written = 0
|
|
445
|
+
while written < len(payload):
|
|
446
|
+
count = os.write(descriptor, payload[written:])
|
|
447
|
+
if count <= 0:
|
|
448
|
+
raise OSError("restore copy made no progress")
|
|
449
|
+
written += count
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def _fsync_directory(path: Path) -> None:
|
|
453
|
+
descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
|
454
|
+
try:
|
|
455
|
+
os.fsync(descriptor)
|
|
456
|
+
finally:
|
|
457
|
+
os.close(descriptor)
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _no_fault(_stage: str) -> None:
|
|
461
|
+
return
|
dploydb/retention.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""Protected, idempotent retention for verified local and remote backups."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import datetime
|
|
8
|
+
from typing import Protocol
|
|
9
|
+
|
|
10
|
+
from dploydb.errors import SafetyCheckError
|
|
11
|
+
from dploydb.models import BackupMetadata, RemoteBackupMetadata
|
|
12
|
+
from dploydb.releases import ReleaseHistorySnapshot
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class LocalRetentionStorage(Protocol):
|
|
16
|
+
"""Small local inventory/deletion boundary used by retention."""
|
|
17
|
+
|
|
18
|
+
def list(self) -> tuple[BackupMetadata, ...]: ...
|
|
19
|
+
|
|
20
|
+
def delete(self, backup_id: str) -> None: ...
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class RemoteRetentionStorage(Protocol):
|
|
24
|
+
"""Small remote inventory/deletion boundary used by retention."""
|
|
25
|
+
|
|
26
|
+
def list(self) -> tuple[RemoteBackupMetadata, ...]: ...
|
|
27
|
+
|
|
28
|
+
def delete(self, backup_id: str) -> None: ...
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True, slots=True)
|
|
32
|
+
class RetentionResult:
|
|
33
|
+
"""Non-secret evidence from one completed retention pass."""
|
|
34
|
+
|
|
35
|
+
protected_backup_ids: tuple[str, ...]
|
|
36
|
+
local_deleted: tuple[str, ...]
|
|
37
|
+
remote_deleted: tuple[str, ...]
|
|
38
|
+
local_retained: tuple[str, ...]
|
|
39
|
+
remote_retained: tuple[str, ...]
|
|
40
|
+
|
|
41
|
+
def as_evidence(self) -> dict[str, object]:
|
|
42
|
+
return {
|
|
43
|
+
"protected_backup_ids": list(self.protected_backup_ids),
|
|
44
|
+
"local_deleted": list(self.local_deleted),
|
|
45
|
+
"remote_deleted": list(self.remote_deleted),
|
|
46
|
+
"local_retained": list(self.local_retained),
|
|
47
|
+
"remote_retained": list(self.remote_retained),
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def protected_backup_ids(
|
|
52
|
+
history: ReleaseHistorySnapshot,
|
|
53
|
+
*,
|
|
54
|
+
project: str,
|
|
55
|
+
) -> frozenset[str]:
|
|
56
|
+
"""Resolve backup protection from one immutable validated history snapshot."""
|
|
57
|
+
pointers = history.pointers
|
|
58
|
+
if pointers is None:
|
|
59
|
+
return frozenset()
|
|
60
|
+
selected_release_ids = [pointers.active_release_id]
|
|
61
|
+
if pointers.previous_release_id is not None:
|
|
62
|
+
selected_release_ids.append(pointers.previous_release_id)
|
|
63
|
+
|
|
64
|
+
protected: set[str] = set()
|
|
65
|
+
for release_id in selected_release_ids:
|
|
66
|
+
release = history.find(release_id)
|
|
67
|
+
if release is None:
|
|
68
|
+
raise _retention_safety_error(
|
|
69
|
+
"release pointers select a manifest missing from the retention snapshot"
|
|
70
|
+
)
|
|
71
|
+
if release.project != project:
|
|
72
|
+
raise _retention_safety_error(
|
|
73
|
+
"release pointers select a different project; backup retention was refused"
|
|
74
|
+
)
|
|
75
|
+
for backup_id in (release.rehearsal_backup_id, release.final_backup_id):
|
|
76
|
+
if backup_id is not None:
|
|
77
|
+
protected.add(backup_id)
|
|
78
|
+
return frozenset(protected)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def apply_retention(
|
|
82
|
+
*,
|
|
83
|
+
project: str,
|
|
84
|
+
keep_last: int,
|
|
85
|
+
history: ReleaseHistorySnapshot,
|
|
86
|
+
local_storage: LocalRetentionStorage,
|
|
87
|
+
remote_storage: RemoteRetentionStorage | None = None,
|
|
88
|
+
) -> RetentionResult:
|
|
89
|
+
"""Delete old unprotected backups from fully verified inventories."""
|
|
90
|
+
if keep_last < 1:
|
|
91
|
+
raise ValueError("keep_last must be positive")
|
|
92
|
+
protected = protected_backup_ids(history, project=project)
|
|
93
|
+
local_records = local_storage.list()
|
|
94
|
+
remote_records = () if remote_storage is None else remote_storage.list()
|
|
95
|
+
|
|
96
|
+
local_delete, local_retain = _plan_inventory(
|
|
97
|
+
((record.backup_id, record.project, record.completed_at) for record in local_records),
|
|
98
|
+
project=project,
|
|
99
|
+
protected=protected,
|
|
100
|
+
keep_last=keep_last,
|
|
101
|
+
)
|
|
102
|
+
remote_delete, remote_retain = _plan_inventory(
|
|
103
|
+
(
|
|
104
|
+
(record.backup.backup_id, record.backup.project, record.backup.completed_at)
|
|
105
|
+
for record in remote_records
|
|
106
|
+
),
|
|
107
|
+
project=project,
|
|
108
|
+
protected=protected,
|
|
109
|
+
keep_last=keep_last,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
for backup_id in local_delete:
|
|
113
|
+
local_storage.delete(backup_id)
|
|
114
|
+
if remote_storage is not None:
|
|
115
|
+
for backup_id in remote_delete:
|
|
116
|
+
remote_storage.delete(backup_id)
|
|
117
|
+
|
|
118
|
+
return RetentionResult(
|
|
119
|
+
protected_backup_ids=tuple(sorted(protected)),
|
|
120
|
+
local_deleted=local_delete,
|
|
121
|
+
remote_deleted=remote_delete,
|
|
122
|
+
local_retained=local_retain,
|
|
123
|
+
remote_retained=remote_retain,
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _plan_inventory(
|
|
128
|
+
records: Iterable[tuple[str, str, datetime]],
|
|
129
|
+
*,
|
|
130
|
+
project: str,
|
|
131
|
+
protected: frozenset[str],
|
|
132
|
+
keep_last: int,
|
|
133
|
+
) -> tuple[tuple[str, ...], tuple[str, ...]]:
|
|
134
|
+
materialized = tuple(records)
|
|
135
|
+
identities = [record[0] for record in materialized]
|
|
136
|
+
if len(identities) != len(set(identities)):
|
|
137
|
+
raise _retention_safety_error("backup inventory contains duplicate identities")
|
|
138
|
+
|
|
139
|
+
managed = [record for record in materialized if record[1] == project]
|
|
140
|
+
foreign_ids = {record[0] for record in materialized if record[1] != project}
|
|
141
|
+
unprotected = [record for record in managed if record[0] not in protected]
|
|
142
|
+
unprotected.sort(key=lambda record: (record[2], record[0]), reverse=True)
|
|
143
|
+
keep_unprotected = {record[0] for record in unprotected[:keep_last]}
|
|
144
|
+
retained = {
|
|
145
|
+
record[0]
|
|
146
|
+
for record in materialized
|
|
147
|
+
if record[0] in protected or record[0] in keep_unprotected or record[0] in foreign_ids
|
|
148
|
+
}
|
|
149
|
+
deleted = sorted(
|
|
150
|
+
(record for record in managed if record[0] not in retained),
|
|
151
|
+
key=lambda record: (record[2], record[0]),
|
|
152
|
+
)
|
|
153
|
+
return tuple(record[0] for record in deleted), tuple(sorted(retained))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _retention_safety_error(detail: str) -> SafetyCheckError:
|
|
157
|
+
return SafetyCheckError(
|
|
158
|
+
detail,
|
|
159
|
+
production_changed=False,
|
|
160
|
+
previous_application_running=None,
|
|
161
|
+
next_safe_action=(
|
|
162
|
+
"Preserve all backups, repair release or backup inventory evidence, then retry "
|
|
163
|
+
"retention."
|
|
164
|
+
),
|
|
165
|
+
)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""Application runner integrations."""
|
|
2
|
+
|
|
3
|
+
from dploydb.runners.base import (
|
|
4
|
+
ApplicationRunner,
|
|
5
|
+
CandidateCleanup,
|
|
6
|
+
CandidateCleanupError,
|
|
7
|
+
CandidateCleanupProof,
|
|
8
|
+
CandidateHandle,
|
|
9
|
+
CandidateInspection,
|
|
10
|
+
CandidateInspectionError,
|
|
11
|
+
CandidateLogs,
|
|
12
|
+
CandidateMount,
|
|
13
|
+
CandidateRunnerError,
|
|
14
|
+
CandidateStart,
|
|
15
|
+
CandidateStartError,
|
|
16
|
+
)
|
|
17
|
+
from dploydb.runners.docker_compose import DockerComposeCandidateRunner
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"ApplicationRunner",
|
|
21
|
+
"CandidateCleanup",
|
|
22
|
+
"CandidateCleanupError",
|
|
23
|
+
"CandidateCleanupProof",
|
|
24
|
+
"CandidateHandle",
|
|
25
|
+
"CandidateInspection",
|
|
26
|
+
"CandidateInspectionError",
|
|
27
|
+
"CandidateLogs",
|
|
28
|
+
"CandidateMount",
|
|
29
|
+
"CandidateRunnerError",
|
|
30
|
+
"CandidateStart",
|
|
31
|
+
"CandidateStartError",
|
|
32
|
+
"DockerComposeCandidateRunner",
|
|
33
|
+
]
|