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/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
+ ]