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/cutover.py ADDED
@@ -0,0 +1,326 @@
1
+ """Final backup, production migration, and pre-traffic database rollback."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import threading
7
+ from collections.abc import Callable, Mapping
8
+ from pathlib import Path
9
+
10
+ from dploydb.backup import create_verified_backup, verify_backup
11
+ from dploydb.config import LoadedConfiguration
12
+ from dploydb.errors import (
13
+ DployDBError,
14
+ ExternalCommandError,
15
+ OperationFailedError,
16
+ RecoveryRequiredError,
17
+ SafetyCheckError,
18
+ )
19
+ from dploydb.migration import MIGRATION_MAX_OUTPUT_BYTES, migration_command_evidence
20
+ from dploydb.models import (
21
+ BackupArtifact,
22
+ BackupPurpose,
23
+ MigrationCommandEvidence,
24
+ ProductionMigrationResult,
25
+ VerifiedDatabaseRestoreResult,
26
+ utc_now,
27
+ )
28
+ from dploydb.restore import FaultInjector, restore_verified_database
29
+ from dploydb.runners.base import CommandExecutor, ProductionStop, validate_operation_id
30
+ from dploydb.sqlite_checks import verify_sqlite_database
31
+ from dploydb.storage.local import LocalBackupStorage
32
+ from dploydb.subprocesses import CommandOutcome, CommandResult, SubprocessRunner
33
+
34
+ MigrationEvidenceSink = Callable[[MigrationCommandEvidence], None]
35
+
36
+
37
+ def create_final_backup(
38
+ loaded: LoadedConfiguration,
39
+ *,
40
+ operation_id: str,
41
+ stopped: ProductionStop,
42
+ ) -> BackupArtifact:
43
+ """Create and reverify the final snapshot only after managed writers stopped."""
44
+ operation = validate_operation_id(operation_id)
45
+ _require_stopped_application(loaded, stopped)
46
+ config = loaded.config
47
+ storage = LocalBackupStorage(config.backup.local_directory)
48
+ artifact = create_verified_backup(
49
+ config.database.path,
50
+ project=loaded.secrets.redact_text(config.project),
51
+ purpose=BackupPurpose.FINAL,
52
+ storage=storage,
53
+ operation_id=operation,
54
+ metadata_source_path=_safe_metadata_path(config.database.path, loaded),
55
+ )
56
+ return _verify_final_backup(
57
+ loaded,
58
+ artifact,
59
+ operation_id=operation,
60
+ production_changed=False,
61
+ )
62
+
63
+
64
+ def migrate_production_database(
65
+ loaded: LoadedConfiguration,
66
+ *,
67
+ operation_id: str,
68
+ stopped: ProductionStop,
69
+ final_backup: BackupArtifact,
70
+ config_path: Path,
71
+ traffic_activated: bool,
72
+ evidence_sink: MigrationEvidenceSink,
73
+ command_environment: Mapping[str, str] | None = None,
74
+ command_runner: CommandExecutor | None = None,
75
+ cancellation_event: threading.Event | None = None,
76
+ log_path: Path,
77
+ ) -> ProductionMigrationResult:
78
+ """Run the rehearsed configured command on production with complete evidence."""
79
+ operation = validate_operation_id(operation_id)
80
+ _require_stopped_application(loaded, stopped)
81
+ if traffic_activated:
82
+ raise SafetyCheckError(
83
+ "production migration cannot start after new traffic was activated",
84
+ production_changed=False,
85
+ previous_application_running=False,
86
+ log_path=log_path,
87
+ next_safe_action="Keep the current release and inspect the traffic state.",
88
+ )
89
+ verified_final = _verify_final_backup(
90
+ loaded,
91
+ final_backup,
92
+ operation_id=operation,
93
+ production_changed=False,
94
+ )
95
+ config = loaded.config
96
+ environment = dict(os.environ if command_environment is None else command_environment)
97
+ environment[config.database.path_env] = str(config.database.path)
98
+ runner = command_runner or SubprocessRunner(
99
+ secrets=loaded.secrets,
100
+ max_output_bytes=MIGRATION_MAX_OUTPUT_BYTES,
101
+ )
102
+ result = runner.run(
103
+ config.migration.command,
104
+ timeout_seconds=config.migration.timeout_seconds,
105
+ environment=environment,
106
+ working_directory=config_path.resolve().parent,
107
+ cancellation_event=cancellation_event,
108
+ )
109
+ evidence = migration_command_evidence(result)
110
+ try:
111
+ evidence_sink(evidence)
112
+ except Exception as error:
113
+ changed = _migration_may_have_changed_production(result)
114
+ raise RecoveryRequiredError(
115
+ "production migration command finished but its durable evidence failed: "
116
+ + loaded.secrets.redact_text(f"{type(error).__name__}: {error}"),
117
+ production_changed=changed,
118
+ previous_application_running=False,
119
+ log_path=log_path,
120
+ next_safe_action=(
121
+ "Keep traffic blocked and applications stopped. Preserve state and restore "
122
+ "the final backup if the migration process started."
123
+ ),
124
+ ) from None
125
+ _require_usable_production_migration(result, log_path=log_path)
126
+ try:
127
+ sqlite = verify_sqlite_database(config.database.path)
128
+ except DployDBError as error:
129
+ raise OperationFailedError(
130
+ "post-migration production SQLite verification failed: " + error.payload.what_failed,
131
+ production_changed=True,
132
+ previous_application_running=False,
133
+ log_path=log_path,
134
+ next_safe_action=(
135
+ "Keep traffic blocked and applications stopped; restore the verified final backup."
136
+ ),
137
+ ) from None
138
+ return ProductionMigrationResult(
139
+ operation_id=operation,
140
+ final_backup_id=verified_final.metadata.backup_id,
141
+ final_backup_sha256=verified_final.metadata.sha256,
142
+ command=evidence,
143
+ sqlite=sqlite,
144
+ completed_at=utc_now(),
145
+ )
146
+
147
+
148
+ def restore_final_backup(
149
+ loaded: LoadedConfiguration,
150
+ *,
151
+ operation_id: str,
152
+ stopped: ProductionStop,
153
+ final_backup: BackupArtifact,
154
+ traffic_activated: bool,
155
+ fault_injector: FaultInjector | None = None,
156
+ ) -> VerifiedDatabaseRestoreResult:
157
+ """Restore the operation's final backup while traffic and database users are blocked."""
158
+ operation = validate_operation_id(operation_id)
159
+ _require_stopped_application(loaded, stopped)
160
+ if traffic_activated:
161
+ raise SafetyCheckError(
162
+ "automatic database restore is forbidden after new traffic was activated",
163
+ production_changed=True,
164
+ previous_application_running=False,
165
+ log_path=loaded.config.database.path,
166
+ next_safe_action=(
167
+ "Keep the current database and use the confirmed manual restore workflow."
168
+ ),
169
+ )
170
+ verified = _verify_final_backup(
171
+ loaded,
172
+ final_backup,
173
+ operation_id=operation,
174
+ production_changed=True,
175
+ )
176
+ restored = restore_verified_database(
177
+ verified,
178
+ loaded.config.database.path,
179
+ application_stopped=True,
180
+ traffic_activated=traffic_activated,
181
+ secrets=loaded.secrets,
182
+ fault_injector=fault_injector,
183
+ )
184
+ if (
185
+ restored.backup_id != verified.metadata.backup_id
186
+ or restored.size_bytes != verified.metadata.size_bytes
187
+ or restored.sha256 != verified.metadata.sha256
188
+ ):
189
+ raise RecoveryRequiredError(
190
+ "database rollback result contradicts the verified final backup",
191
+ production_changed=True,
192
+ previous_application_running=False,
193
+ log_path=loaded.config.database.path,
194
+ next_safe_action="Keep applications stopped and verify the final backup manually.",
195
+ )
196
+ return restored
197
+
198
+
199
+ def _require_stopped_application(
200
+ loaded: LoadedConfiguration,
201
+ stopped: ProductionStop,
202
+ ) -> None:
203
+ if not isinstance(stopped, ProductionStop):
204
+ raise TypeError("stopped must be a ProductionStop proof")
205
+ handle = stopped.handle
206
+ if (
207
+ stopped.inspection.handle != handle
208
+ or stopped.inspection.running
209
+ or not stopped.command.succeeded
210
+ or stopped.command.stdout.truncated
211
+ or stopped.command.stderr.truncated
212
+ or not stopped.inspection.command.succeeded
213
+ or stopped.inspection.command.stdout.truncated
214
+ or stopped.inspection.command.stderr.truncated
215
+ or handle.database_directory.resolve() != loaded.config.database.path.parent.resolve()
216
+ or handle.database_target != loaded.config.application.database_volume_target
217
+ ):
218
+ raise SafetyCheckError(
219
+ "final database operation requires matching proof that production is stopped",
220
+ production_changed=False,
221
+ previous_application_running=None,
222
+ log_path=loaded.config.database.path,
223
+ next_safe_action=(
224
+ "Do not access production; stop and inspect the exact current application first."
225
+ ),
226
+ )
227
+
228
+
229
+ def _verify_final_backup(
230
+ loaded: LoadedConfiguration,
231
+ artifact: BackupArtifact,
232
+ *,
233
+ operation_id: str,
234
+ production_changed: bool,
235
+ ) -> BackupArtifact:
236
+ storage = LocalBackupStorage(loaded.config.backup.local_directory)
237
+ try:
238
+ verified = verify_backup(storage, artifact.metadata.backup_id)
239
+ except DployDBError as error:
240
+ raise SafetyCheckError(
241
+ "final backup verification failed: " + error.payload.what_failed,
242
+ production_changed=production_changed,
243
+ previous_application_running=False,
244
+ log_path=artifact.metadata_path,
245
+ next_safe_action="Do not migrate or restore production; create a new final backup.",
246
+ ) from None
247
+ if (
248
+ verified != artifact
249
+ or verified.metadata.purpose is not BackupPurpose.FINAL
250
+ or verified.metadata.operation_id != operation_id
251
+ or verified.metadata.project != loaded.secrets.redact_text(loaded.config.project)
252
+ ):
253
+ raise SafetyCheckError(
254
+ "final backup identity does not match the active deployment operation",
255
+ production_changed=production_changed,
256
+ previous_application_running=False,
257
+ log_path=artifact.metadata_path,
258
+ next_safe_action="Do not migrate or restore production; create a new final backup.",
259
+ )
260
+ return verified
261
+
262
+
263
+ def _require_usable_production_migration(
264
+ result: CommandResult,
265
+ *,
266
+ log_path: Path,
267
+ ) -> None:
268
+ if result.outcome is CommandOutcome.SUCCEEDED:
269
+ if not result.stdout.truncated and not result.stderr.truncated:
270
+ return
271
+ raise ExternalCommandError(
272
+ "production migration succeeded but complete output evidence was unavailable",
273
+ production_changed=True,
274
+ previous_application_running=False,
275
+ log_path=log_path,
276
+ next_safe_action=(
277
+ "Keep traffic blocked and applications stopped; restore the final backup."
278
+ ),
279
+ )
280
+ if result.outcome is CommandOutcome.CLEANUP_FAILED:
281
+ raise RecoveryRequiredError(
282
+ "production migration process cleanup could not be proven: "
283
+ + (result.cleanup_error or "unknown cleanup failure"),
284
+ production_changed=True,
285
+ previous_application_running=False,
286
+ log_path=log_path,
287
+ next_safe_action=(
288
+ "Keep traffic blocked. Confirm the migration process group is gone before "
289
+ "restoring the final backup."
290
+ ),
291
+ )
292
+ production_changed = _migration_may_have_changed_production(result)
293
+ if result.outcome is CommandOutcome.NONZERO_EXIT:
294
+ detail = f"exited with status {result.exit_code}"
295
+ elif result.outcome is CommandOutcome.TIMED_OUT:
296
+ detail = "timed out and its process group was terminated"
297
+ elif result.outcome is CommandOutcome.CANCELLED:
298
+ detail = "was cancelled and its process group was terminated"
299
+ else:
300
+ detail = "could not start: " + (result.start_error or "unknown start failure")
301
+ raise ExternalCommandError(
302
+ "production migration " + detail,
303
+ production_changed=production_changed,
304
+ previous_application_running=False,
305
+ log_path=log_path,
306
+ next_safe_action=(
307
+ "Keep traffic blocked and applications stopped; restore the final backup if the "
308
+ "migration process started."
309
+ ),
310
+ )
311
+
312
+
313
+ def _migration_may_have_changed_production(result: CommandResult) -> bool:
314
+ return (
315
+ result.outcome
316
+ not in {
317
+ CommandOutcome.START_FAILED,
318
+ CommandOutcome.CANCELLED,
319
+ }
320
+ or result.exit_code is not None
321
+ )
322
+
323
+
324
+ def _safe_metadata_path(path: Path, loaded: LoadedConfiguration) -> Path:
325
+ safe = Path(loaded.secrets.redact_text(str(path)))
326
+ return safe if safe.is_absolute() else Path("/[REDACTED]")