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/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]")
|