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
|
@@ -0,0 +1,825 @@
|
|
|
1
|
+
"""Release-aware manual restore preview and controlled execution."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import threading
|
|
7
|
+
from collections.abc import Callable, Mapping
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from dploydb.backup import create_verified_backup, open_verified_configured_backup
|
|
12
|
+
from dploydb.config import (
|
|
13
|
+
LoadedConfiguration,
|
|
14
|
+
configuration_fingerprint,
|
|
15
|
+
require_deploy_topology,
|
|
16
|
+
)
|
|
17
|
+
from dploydb.deployment_dependencies import (
|
|
18
|
+
DeploymentDependencies,
|
|
19
|
+
ProductionHealthBoundary,
|
|
20
|
+
default_dependencies,
|
|
21
|
+
)
|
|
22
|
+
from dploydb.deployment_evidence import (
|
|
23
|
+
health_summary,
|
|
24
|
+
hook_summary,
|
|
25
|
+
inspection_evidence,
|
|
26
|
+
restart_evidence,
|
|
27
|
+
stop_evidence,
|
|
28
|
+
)
|
|
29
|
+
from dploydb.errors import (
|
|
30
|
+
DployDBError,
|
|
31
|
+
ExternalCommandError,
|
|
32
|
+
OperationFailedError,
|
|
33
|
+
RecoveryRequiredError,
|
|
34
|
+
SafetyCheckError,
|
|
35
|
+
)
|
|
36
|
+
from dploydb.health import ApplicationHealthChecker
|
|
37
|
+
from dploydb.locking import DeploymentLock
|
|
38
|
+
from dploydb.migration import require_clean_operation_state
|
|
39
|
+
from dploydb.models import (
|
|
40
|
+
BackupArtifact,
|
|
41
|
+
BackupMetadata,
|
|
42
|
+
BackupPurpose,
|
|
43
|
+
DeploymentState,
|
|
44
|
+
FailureRecord,
|
|
45
|
+
OperationManifest,
|
|
46
|
+
OperationStatus,
|
|
47
|
+
ProductionApplicationHandle,
|
|
48
|
+
ReleaseManifest,
|
|
49
|
+
ReleasePointers,
|
|
50
|
+
SafetyFacts,
|
|
51
|
+
VerifiedDatabaseRestoreResult,
|
|
52
|
+
)
|
|
53
|
+
from dploydb.releases import ReleaseStore
|
|
54
|
+
from dploydb.restore import restore_verified_database
|
|
55
|
+
from dploydb.runners.base import ProductionApplicationRunner
|
|
56
|
+
from dploydb.sqlite_checks import verify_sqlite_database
|
|
57
|
+
from dploydb.state import StateStore
|
|
58
|
+
from dploydb.storage.base import RemoteBackupStorage
|
|
59
|
+
from dploydb.storage.local import LocalBackupStorage
|
|
60
|
+
from dploydb.subprocesses import CommandOutcome
|
|
61
|
+
from dploydb.traffic import TrafficController, TrafficHookResult
|
|
62
|
+
|
|
63
|
+
DATA_LOSS_WARNING = (
|
|
64
|
+
"WARNING: restoring an older release replaces the current production database and may "
|
|
65
|
+
"permanently discard data written after that release. DployDB will first create and verify "
|
|
66
|
+
"a backup of the current state."
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True, slots=True)
|
|
71
|
+
class RestoreSelection:
|
|
72
|
+
"""Fully verified release-to-backup/application mapping used by manual restore."""
|
|
73
|
+
|
|
74
|
+
active_release: ReleaseManifest
|
|
75
|
+
selected_release: ReleaseManifest
|
|
76
|
+
selected_backup_metadata: BackupMetadata
|
|
77
|
+
current_application: ProductionApplicationHandle
|
|
78
|
+
selected_application: ProductionApplicationHandle
|
|
79
|
+
database_path: Path
|
|
80
|
+
|
|
81
|
+
def as_dict(self) -> dict[str, object]:
|
|
82
|
+
return {
|
|
83
|
+
"active_release_id": self.active_release.release_id,
|
|
84
|
+
"active_version": self.active_release.requested_version,
|
|
85
|
+
"selected_release_id": self.selected_release.release_id,
|
|
86
|
+
"selected_version": self.selected_release.requested_version,
|
|
87
|
+
"selected_backup_id": self.selected_backup_metadata.backup_id,
|
|
88
|
+
"selected_backup_sha256": self.selected_backup_metadata.sha256,
|
|
89
|
+
"current_container_id": self.current_application.container_id,
|
|
90
|
+
"current_container_name": self.current_application.container_name,
|
|
91
|
+
"selected_container_id": self.selected_application.container_id,
|
|
92
|
+
"selected_container_name": self.selected_application.container_name,
|
|
93
|
+
"database_path": str(self.database_path),
|
|
94
|
+
"data_loss_possible": True,
|
|
95
|
+
"pre_restore_backup_required": True,
|
|
96
|
+
"warning": DATA_LOSS_WARNING,
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True, slots=True)
|
|
101
|
+
class ManualRestoreDependencies:
|
|
102
|
+
"""Narrow operational boundaries used by the manual restore coordinator."""
|
|
103
|
+
|
|
104
|
+
production: ProductionApplicationRunner
|
|
105
|
+
traffic: TrafficController
|
|
106
|
+
health: ProductionHealthBoundary
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass(frozen=True, slots=True)
|
|
110
|
+
class ManualRestoreResult:
|
|
111
|
+
"""Proven terminal result of a successful release-aware manual restore."""
|
|
112
|
+
|
|
113
|
+
operation: OperationManifest
|
|
114
|
+
selected_release: ReleaseManifest
|
|
115
|
+
replaced_release: ReleaseManifest
|
|
116
|
+
pre_restore_backup: BackupArtifact
|
|
117
|
+
database_restore: VerifiedDatabaseRestoreResult
|
|
118
|
+
pointers: ReleasePointers
|
|
119
|
+
operation_log_path: Path
|
|
120
|
+
|
|
121
|
+
def as_dict(self) -> dict[str, object]:
|
|
122
|
+
return {
|
|
123
|
+
"ok": True,
|
|
124
|
+
"command": "restore",
|
|
125
|
+
"outcome": "manual_restore_completed",
|
|
126
|
+
"operation_id": self.operation.operation_id,
|
|
127
|
+
"selected_release_id": self.selected_release.release_id,
|
|
128
|
+
"selected_version": self.selected_release.requested_version,
|
|
129
|
+
"replaced_release_id": self.replaced_release.release_id,
|
|
130
|
+
"pre_restore_backup_id": self.pre_restore_backup.metadata.backup_id,
|
|
131
|
+
"restored_backup_id": self.database_restore.backup_id,
|
|
132
|
+
"database_sha256": self.database_restore.sha256,
|
|
133
|
+
"active_release_id": self.pointers.active_release_id,
|
|
134
|
+
"previous_release_id": self.pointers.previous_release_id,
|
|
135
|
+
"production_changed": True,
|
|
136
|
+
"previous_application_running": True,
|
|
137
|
+
"recovery_required": False,
|
|
138
|
+
"data_loss_warning": DATA_LOSS_WARNING,
|
|
139
|
+
"log_path": str(self.operation_log_path),
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass(slots=True)
|
|
144
|
+
class _RestoreContext:
|
|
145
|
+
loaded: LoadedConfiguration
|
|
146
|
+
selection: RestoreSelection
|
|
147
|
+
operation_id: str
|
|
148
|
+
store: StateStore
|
|
149
|
+
releases: ReleaseStore
|
|
150
|
+
dependencies: ManualRestoreDependencies
|
|
151
|
+
operation_log: Path
|
|
152
|
+
cancellation_event: threading.Event | None
|
|
153
|
+
maintenance_enabled: bool = False
|
|
154
|
+
current_stopped: bool = False
|
|
155
|
+
current_stop_attempted: bool = False
|
|
156
|
+
selected_running: bool = False
|
|
157
|
+
selected_restart_attempted: bool = False
|
|
158
|
+
database_replaced: bool = False
|
|
159
|
+
traffic_activation_attempted: bool = False
|
|
160
|
+
normal_traffic_may_be_enabled: bool = False
|
|
161
|
+
selected_backup: BackupArtifact | None = None
|
|
162
|
+
pre_restore_backup: BackupArtifact | None = None
|
|
163
|
+
database_restore: VerifiedDatabaseRestoreResult | None = None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
FaultInjector = Callable[[str], None]
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def preview_configured_restore(
|
|
170
|
+
loaded: LoadedConfiguration,
|
|
171
|
+
release_id: str,
|
|
172
|
+
*,
|
|
173
|
+
environment: Mapping[str, str] | None = None,
|
|
174
|
+
remote_storage: RemoteBackupStorage | None = None,
|
|
175
|
+
) -> RestoreSelection:
|
|
176
|
+
"""Resolve the protected previous release without mutating any local state."""
|
|
177
|
+
releases = ReleaseStore(loaded.config.state_directory, secrets=loaded.secrets)
|
|
178
|
+
selected, pointers = releases.lookup_history_release(release_id)
|
|
179
|
+
if pointers is None or pointers.previous_release_id is None:
|
|
180
|
+
raise SafetyCheckError(
|
|
181
|
+
"there is no protected previous release to restore",
|
|
182
|
+
production_changed=False,
|
|
183
|
+
previous_application_running=None,
|
|
184
|
+
log_path=releases.releases_directory,
|
|
185
|
+
next_safe_action="Deploy a verified release before requesting a previous restore.",
|
|
186
|
+
)
|
|
187
|
+
if release_id != pointers.previous_release_id:
|
|
188
|
+
raise SafetyCheckError(
|
|
189
|
+
"manual restore is limited to the immediately previous protected release",
|
|
190
|
+
production_changed=False,
|
|
191
|
+
previous_application_running=None,
|
|
192
|
+
log_path=releases.pointer_path,
|
|
193
|
+
next_safe_action=(
|
|
194
|
+
f"Select protected previous release {pointers.previous_release_id}; older "
|
|
195
|
+
"history is read-only during the hackathon."
|
|
196
|
+
),
|
|
197
|
+
)
|
|
198
|
+
active = releases.read_manifest(pointers.active_release_id)
|
|
199
|
+
if active.status is not DeploymentState.ACTIVE or selected.status is not DeploymentState.ACTIVE:
|
|
200
|
+
raise SafetyCheckError(
|
|
201
|
+
"active/previous release status is not safe for manual restore",
|
|
202
|
+
production_changed=False,
|
|
203
|
+
previous_application_running=None,
|
|
204
|
+
log_path=releases.pointer_path,
|
|
205
|
+
next_safe_action="Run dploydb status and resolve release-state contradictions first.",
|
|
206
|
+
)
|
|
207
|
+
if active.previous_release_id != selected.release_id:
|
|
208
|
+
raise SafetyCheckError(
|
|
209
|
+
"active release lineage does not match the protected previous pointer",
|
|
210
|
+
production_changed=False,
|
|
211
|
+
previous_application_running=None,
|
|
212
|
+
log_path=releases.pointer_path,
|
|
213
|
+
next_safe_action="Preserve release state and inspect the active manifest manually.",
|
|
214
|
+
)
|
|
215
|
+
current_application = active.new_application
|
|
216
|
+
selected_application = selected.new_application
|
|
217
|
+
if current_application is None or selected_application is None:
|
|
218
|
+
raise SafetyCheckError(
|
|
219
|
+
"manual restore requires exact active and previous application identities",
|
|
220
|
+
production_changed=False,
|
|
221
|
+
previous_application_running=None,
|
|
222
|
+
log_path=active.operation_log_path,
|
|
223
|
+
next_safe_action="Do not guess a container; preserve the release manifests.",
|
|
224
|
+
)
|
|
225
|
+
if active.previous_application != selected_application:
|
|
226
|
+
raise SafetyCheckError(
|
|
227
|
+
"active release does not preserve the selected previous application identity",
|
|
228
|
+
production_changed=False,
|
|
229
|
+
previous_application_running=None,
|
|
230
|
+
log_path=active.operation_log_path,
|
|
231
|
+
next_safe_action="Do not restore until the application identity is reconciled.",
|
|
232
|
+
)
|
|
233
|
+
if current_application.container_id == selected_application.container_id:
|
|
234
|
+
raise SafetyCheckError(
|
|
235
|
+
"active and selected releases unexpectedly identify the same container",
|
|
236
|
+
production_changed=False,
|
|
237
|
+
previous_application_running=None,
|
|
238
|
+
log_path=active.operation_log_path,
|
|
239
|
+
next_safe_action="Inspect the release manifests and live Docker state.",
|
|
240
|
+
)
|
|
241
|
+
if active.final_backup_id is None or active.final_backup_sha256 is None:
|
|
242
|
+
raise SafetyCheckError(
|
|
243
|
+
"active release does not reference its verified pre-migration final backup",
|
|
244
|
+
production_changed=False,
|
|
245
|
+
previous_application_running=None,
|
|
246
|
+
log_path=active.operation_log_path,
|
|
247
|
+
next_safe_action="Do not restore an older release without its final backup.",
|
|
248
|
+
)
|
|
249
|
+
with open_verified_configured_backup(
|
|
250
|
+
loaded,
|
|
251
|
+
active.final_backup_id,
|
|
252
|
+
environment=environment,
|
|
253
|
+
remote_storage=remote_storage,
|
|
254
|
+
) as backup:
|
|
255
|
+
if (
|
|
256
|
+
backup.metadata.purpose is not BackupPurpose.FINAL
|
|
257
|
+
or backup.metadata.operation_id != active.operation_id
|
|
258
|
+
or backup.metadata.sha256 != active.final_backup_sha256
|
|
259
|
+
):
|
|
260
|
+
raise SafetyCheckError(
|
|
261
|
+
"selected restore backup contradicts the active release manifest",
|
|
262
|
+
production_changed=False,
|
|
263
|
+
previous_application_running=None,
|
|
264
|
+
log_path=backup.metadata_path,
|
|
265
|
+
next_safe_action="Preserve the backup and release evidence; do not restore it.",
|
|
266
|
+
)
|
|
267
|
+
backup_metadata = backup.metadata
|
|
268
|
+
return RestoreSelection(
|
|
269
|
+
active_release=active,
|
|
270
|
+
selected_release=selected,
|
|
271
|
+
selected_backup_metadata=backup_metadata,
|
|
272
|
+
current_application=current_application,
|
|
273
|
+
selected_application=selected_application,
|
|
274
|
+
database_path=loaded.config.database.path,
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def restore_configured_release(
|
|
279
|
+
loaded: LoadedConfiguration,
|
|
280
|
+
release_id: str,
|
|
281
|
+
*,
|
|
282
|
+
config_path: Path,
|
|
283
|
+
command_environment: Mapping[str, str] | None = None,
|
|
284
|
+
remote_storage: RemoteBackupStorage | None = None,
|
|
285
|
+
dependencies: ManualRestoreDependencies | None = None,
|
|
286
|
+
cancellation_event: threading.Event | None = None,
|
|
287
|
+
fault_injector: FaultInjector | None = None,
|
|
288
|
+
) -> ManualRestoreResult:
|
|
289
|
+
"""Restore the protected previous release under one checked operation and lock."""
|
|
290
|
+
topology = require_deploy_topology(loaded.config)
|
|
291
|
+
selected_environment = dict(os.environ if command_environment is None else command_environment)
|
|
292
|
+
working_directory = config_path.resolve().parent
|
|
293
|
+
store = StateStore(loaded.config.state_directory, secrets=loaded.secrets)
|
|
294
|
+
releases = ReleaseStore(loaded.config.state_directory, secrets=loaded.secrets)
|
|
295
|
+
lock = DeploymentLock(loaded.config.state_directory, secrets=loaded.secrets)
|
|
296
|
+
owned_health: ApplicationHealthChecker | None = None
|
|
297
|
+
inject = fault_injector or _no_fault
|
|
298
|
+
|
|
299
|
+
with lock:
|
|
300
|
+
require_clean_operation_state(lock, store)
|
|
301
|
+
selection = preview_configured_restore(
|
|
302
|
+
loaded,
|
|
303
|
+
release_id,
|
|
304
|
+
environment=selected_environment,
|
|
305
|
+
remote_storage=remote_storage,
|
|
306
|
+
)
|
|
307
|
+
selected_dependencies = dependencies
|
|
308
|
+
if selected_dependencies is None:
|
|
309
|
+
deployment_dependencies, owned_health = default_dependencies(
|
|
310
|
+
loaded,
|
|
311
|
+
topology=topology,
|
|
312
|
+
config_path=config_path,
|
|
313
|
+
working_directory=working_directory,
|
|
314
|
+
command_environment=selected_environment,
|
|
315
|
+
candidate_command_runner=None,
|
|
316
|
+
candidate_application_runner=None,
|
|
317
|
+
candidate_health_checker=None,
|
|
318
|
+
)
|
|
319
|
+
selected_dependencies = _manual_dependencies(deployment_dependencies)
|
|
320
|
+
operation = store.create_operation(
|
|
321
|
+
operation_type="restore",
|
|
322
|
+
project=loaded.config.project,
|
|
323
|
+
configuration_fingerprint=configuration_fingerprint(
|
|
324
|
+
loaded.config, secrets=loaded.secrets
|
|
325
|
+
),
|
|
326
|
+
stage="restore_previewed",
|
|
327
|
+
evidence=selection.as_dict(),
|
|
328
|
+
)
|
|
329
|
+
lock.record_owner(operation_id=operation.operation_id, operation_type="restore")
|
|
330
|
+
context = _RestoreContext(
|
|
331
|
+
loaded=loaded,
|
|
332
|
+
selection=selection,
|
|
333
|
+
operation_id=operation.operation_id,
|
|
334
|
+
store=store,
|
|
335
|
+
releases=releases,
|
|
336
|
+
dependencies=selected_dependencies,
|
|
337
|
+
operation_log=store.operation_paths(operation.operation_id).events,
|
|
338
|
+
cancellation_event=cancellation_event,
|
|
339
|
+
)
|
|
340
|
+
try:
|
|
341
|
+
with open_verified_configured_backup(
|
|
342
|
+
loaded,
|
|
343
|
+
selection.selected_backup_metadata.backup_id,
|
|
344
|
+
environment=selected_environment,
|
|
345
|
+
remote_storage=remote_storage,
|
|
346
|
+
) as selected_backup:
|
|
347
|
+
context.selected_backup = selected_backup
|
|
348
|
+
return _run_manual_restore(context, inject=inject)
|
|
349
|
+
except DployDBError as error:
|
|
350
|
+
_finish_restore_failure(context, error)
|
|
351
|
+
raise
|
|
352
|
+
except Exception as raw_error:
|
|
353
|
+
recovery = RecoveryRequiredError(
|
|
354
|
+
"manual restore coordinator failed unexpectedly: "
|
|
355
|
+
+ loaded.secrets.redact_text(f"{type(raw_error).__name__}: {raw_error}"),
|
|
356
|
+
production_changed=context.database_replaced,
|
|
357
|
+
previous_application_running=False if context.current_stopped else None,
|
|
358
|
+
log_path=context.operation_log,
|
|
359
|
+
next_safe_action=(
|
|
360
|
+
"Keep maintenance enabled, preserve the pre-restore backup and operation "
|
|
361
|
+
"evidence, and inspect both recorded applications."
|
|
362
|
+
),
|
|
363
|
+
)
|
|
364
|
+
_finish_restore_failure(context, recovery)
|
|
365
|
+
raise recovery from None
|
|
366
|
+
finally:
|
|
367
|
+
if owned_health is not None:
|
|
368
|
+
owned_health.close()
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
def _run_manual_restore(
|
|
372
|
+
context: _RestoreContext,
|
|
373
|
+
*,
|
|
374
|
+
inject: FaultInjector,
|
|
375
|
+
) -> ManualRestoreResult:
|
|
376
|
+
try:
|
|
377
|
+
current = context.dependencies.production.inspect(
|
|
378
|
+
context.selection.current_application,
|
|
379
|
+
expected_running=True,
|
|
380
|
+
cancellation_event=context.cancellation_event,
|
|
381
|
+
)
|
|
382
|
+
selected = context.dependencies.production.inspect(
|
|
383
|
+
context.selection.selected_application,
|
|
384
|
+
expected_running=False,
|
|
385
|
+
cancellation_event=context.cancellation_event,
|
|
386
|
+
)
|
|
387
|
+
context.store.append_event(
|
|
388
|
+
context.operation_id,
|
|
389
|
+
message="Active and selected application identities were inspected.",
|
|
390
|
+
evidence={
|
|
391
|
+
"current_application": inspection_evidence(current),
|
|
392
|
+
"selected_application": inspection_evidence(selected),
|
|
393
|
+
},
|
|
394
|
+
)
|
|
395
|
+
_enable_restore_maintenance(context)
|
|
396
|
+
context.current_stop_attempted = True
|
|
397
|
+
stopped = context.dependencies.production.stop_current(
|
|
398
|
+
context.selection.current_application,
|
|
399
|
+
cancellation_event=context.cancellation_event,
|
|
400
|
+
)
|
|
401
|
+
context.current_stopped = True
|
|
402
|
+
context.store.transition(
|
|
403
|
+
context.operation_id,
|
|
404
|
+
status=OperationStatus.IN_PROGRESS,
|
|
405
|
+
stage="manual_restore_current_app_stopped",
|
|
406
|
+
message="The exact current application was stopped and inspected.",
|
|
407
|
+
evidence={"production_stop": stop_evidence(stopped)},
|
|
408
|
+
safety=SafetyFacts(
|
|
409
|
+
production_changed=False,
|
|
410
|
+
previous_application_running=False,
|
|
411
|
+
recovery_required=False,
|
|
412
|
+
),
|
|
413
|
+
)
|
|
414
|
+
context.pre_restore_backup = create_verified_backup(
|
|
415
|
+
context.loaded.config.database.path,
|
|
416
|
+
project=context.loaded.secrets.redact_text(context.loaded.config.project),
|
|
417
|
+
purpose=BackupPurpose.PRE_RESTORE,
|
|
418
|
+
storage=LocalBackupStorage(context.loaded.config.backup.local_directory),
|
|
419
|
+
operation_id=context.operation_id,
|
|
420
|
+
metadata_source_path=context.loaded.config.database.path,
|
|
421
|
+
)
|
|
422
|
+
context.store.transition(
|
|
423
|
+
context.operation_id,
|
|
424
|
+
status=OperationStatus.IN_PROGRESS,
|
|
425
|
+
stage="manual_restore_prepared",
|
|
426
|
+
message="The selected and current-state backups are verified.",
|
|
427
|
+
evidence={
|
|
428
|
+
"selected_backup_id": context.selection.selected_backup_metadata.backup_id,
|
|
429
|
+
"pre_restore_backup_id": context.pre_restore_backup.metadata.backup_id,
|
|
430
|
+
},
|
|
431
|
+
safety=SafetyFacts(
|
|
432
|
+
production_changed=False,
|
|
433
|
+
previous_application_running=False,
|
|
434
|
+
recovery_required=False,
|
|
435
|
+
),
|
|
436
|
+
)
|
|
437
|
+
inject("after_pre_restore_backup")
|
|
438
|
+
context.store.transition(
|
|
439
|
+
context.operation_id,
|
|
440
|
+
status=OperationStatus.IN_PROGRESS,
|
|
441
|
+
stage="manual_restore_started",
|
|
442
|
+
message="Production database replacement is starting with traffic blocked.",
|
|
443
|
+
safety=SafetyFacts(
|
|
444
|
+
production_changed=True,
|
|
445
|
+
previous_application_running=False,
|
|
446
|
+
recovery_required=False,
|
|
447
|
+
),
|
|
448
|
+
)
|
|
449
|
+
assert context.selected_backup is not None
|
|
450
|
+
context.database_restore = restore_verified_database(
|
|
451
|
+
context.selected_backup,
|
|
452
|
+
context.loaded.config.database.path,
|
|
453
|
+
application_stopped=True,
|
|
454
|
+
traffic_activated=False,
|
|
455
|
+
secrets=context.loaded.secrets,
|
|
456
|
+
)
|
|
457
|
+
context.database_replaced = True
|
|
458
|
+
context.store.append_event(
|
|
459
|
+
context.operation_id,
|
|
460
|
+
message="The selected previous database was restored and verified.",
|
|
461
|
+
evidence={"database_restore": context.database_restore.model_dump(mode="json")},
|
|
462
|
+
)
|
|
463
|
+
inject("after_database_restore")
|
|
464
|
+
context.selected_restart_attempted = True
|
|
465
|
+
restarted = context.dependencies.production.restart_previous(
|
|
466
|
+
context.selection.selected_application,
|
|
467
|
+
cancellation_event=context.cancellation_event,
|
|
468
|
+
)
|
|
469
|
+
context.selected_running = True
|
|
470
|
+
context.store.append_event(
|
|
471
|
+
context.operation_id,
|
|
472
|
+
message="The selected previous application was restarted and inspected.",
|
|
473
|
+
evidence={"selected_restart": restart_evidence(restarted)},
|
|
474
|
+
)
|
|
475
|
+
sqlite = verify_sqlite_database(context.loaded.config.database.path)
|
|
476
|
+
health = context.dependencies.health.check_application(
|
|
477
|
+
version=context.selection.selected_release.requested_version,
|
|
478
|
+
database_path=context.loaded.config.database.path,
|
|
479
|
+
cancellation_event=context.cancellation_event,
|
|
480
|
+
)
|
|
481
|
+
context.store.transition(
|
|
482
|
+
context.operation_id,
|
|
483
|
+
status=OperationStatus.IN_PROGRESS,
|
|
484
|
+
stage="manual_restore_selected_healthy",
|
|
485
|
+
message="The selected database and application passed final checks.",
|
|
486
|
+
evidence={
|
|
487
|
+
"database": sqlite.model_dump(mode="json"),
|
|
488
|
+
"application_health": health.as_evidence(),
|
|
489
|
+
"health_summary": health_summary(
|
|
490
|
+
health,
|
|
491
|
+
role="previous",
|
|
492
|
+
version=context.selection.selected_release.requested_version,
|
|
493
|
+
).model_dump(mode="json"),
|
|
494
|
+
},
|
|
495
|
+
safety=SafetyFacts(
|
|
496
|
+
production_changed=True,
|
|
497
|
+
previous_application_running=True,
|
|
498
|
+
recovery_required=False,
|
|
499
|
+
),
|
|
500
|
+
)
|
|
501
|
+
inject("before_traffic_activation")
|
|
502
|
+
context.traffic_activation_attempted = True
|
|
503
|
+
context.store.transition(
|
|
504
|
+
context.operation_id,
|
|
505
|
+
status=OperationStatus.IN_PROGRESS,
|
|
506
|
+
stage="manual_restore_traffic_activation_started",
|
|
507
|
+
message="Selected-release traffic activation is about to run.",
|
|
508
|
+
safety=SafetyFacts(
|
|
509
|
+
production_changed=True,
|
|
510
|
+
previous_application_running=True,
|
|
511
|
+
recovery_required=False,
|
|
512
|
+
),
|
|
513
|
+
)
|
|
514
|
+
old_target = context.dependencies.traffic.activate_old(
|
|
515
|
+
cancellation_event=context.cancellation_event
|
|
516
|
+
)
|
|
517
|
+
_record_restore_hook(context, old_target)
|
|
518
|
+
if not old_target.passed:
|
|
519
|
+
context.traffic_activation_attempted = _hook_may_have_run(old_target)
|
|
520
|
+
raise _restore_hook_error(old_target, context)
|
|
521
|
+
context.store.transition(
|
|
522
|
+
context.operation_id,
|
|
523
|
+
status=OperationStatus.IN_PROGRESS,
|
|
524
|
+
stage="manual_restore_traffic_target_selected",
|
|
525
|
+
message="The selected previous traffic target is active behind maintenance.",
|
|
526
|
+
evidence={"traffic_hook": old_target.as_evidence()},
|
|
527
|
+
safety=SafetyFacts(
|
|
528
|
+
production_changed=True,
|
|
529
|
+
previous_application_running=True,
|
|
530
|
+
recovery_required=False,
|
|
531
|
+
),
|
|
532
|
+
)
|
|
533
|
+
context.normal_traffic_may_be_enabled = True
|
|
534
|
+
context.store.transition(
|
|
535
|
+
context.operation_id,
|
|
536
|
+
status=OperationStatus.IN_PROGRESS,
|
|
537
|
+
stage="manual_restore_maintenance_disable_started",
|
|
538
|
+
message="Maintenance disable is about to expose the selected previous release.",
|
|
539
|
+
safety=SafetyFacts(
|
|
540
|
+
production_changed=True,
|
|
541
|
+
previous_application_running=True,
|
|
542
|
+
recovery_required=False,
|
|
543
|
+
),
|
|
544
|
+
)
|
|
545
|
+
maintenance_off = context.dependencies.traffic.disable_maintenance(
|
|
546
|
+
cancellation_event=context.cancellation_event
|
|
547
|
+
)
|
|
548
|
+
_record_restore_hook(context, maintenance_off)
|
|
549
|
+
if not maintenance_off.passed:
|
|
550
|
+
raise _restore_hook_error(maintenance_off, context, recovery=True)
|
|
551
|
+
context.maintenance_enabled = False
|
|
552
|
+
inject("after_traffic_activation")
|
|
553
|
+
pointers = context.releases.activate_release(context.selection.selected_release.release_id)
|
|
554
|
+
assert context.pre_restore_backup is not None
|
|
555
|
+
assert context.database_restore is not None
|
|
556
|
+
operation = context.store.transition(
|
|
557
|
+
context.operation_id,
|
|
558
|
+
status=OperationStatus.SUCCEEDED,
|
|
559
|
+
stage="manual_restore_completed",
|
|
560
|
+
message="The protected previous release is active and verified.",
|
|
561
|
+
evidence={
|
|
562
|
+
"selected_release_id": context.selection.selected_release.release_id,
|
|
563
|
+
"replaced_release_id": context.selection.active_release.release_id,
|
|
564
|
+
"pre_restore_backup_id": context.pre_restore_backup.metadata.backup_id,
|
|
565
|
+
"active_release_id": pointers.active_release_id,
|
|
566
|
+
"previous_release_id": pointers.previous_release_id,
|
|
567
|
+
},
|
|
568
|
+
safety=SafetyFacts(
|
|
569
|
+
production_changed=True,
|
|
570
|
+
previous_application_running=True,
|
|
571
|
+
recovery_required=False,
|
|
572
|
+
),
|
|
573
|
+
)
|
|
574
|
+
return ManualRestoreResult(
|
|
575
|
+
operation=operation,
|
|
576
|
+
selected_release=context.selection.selected_release,
|
|
577
|
+
replaced_release=context.selection.active_release,
|
|
578
|
+
pre_restore_backup=context.pre_restore_backup,
|
|
579
|
+
database_restore=context.database_restore,
|
|
580
|
+
pointers=pointers,
|
|
581
|
+
operation_log_path=context.operation_log,
|
|
582
|
+
)
|
|
583
|
+
except BaseException as raw_error:
|
|
584
|
+
error = _normalize_restore_coordinator_error(raw_error, context)
|
|
585
|
+
if error is None:
|
|
586
|
+
raise
|
|
587
|
+
if (
|
|
588
|
+
error.payload.recovery_required
|
|
589
|
+
or context.traffic_activation_attempted
|
|
590
|
+
or context.normal_traffic_may_be_enabled
|
|
591
|
+
):
|
|
592
|
+
recovery = RecoveryRequiredError(
|
|
593
|
+
"manual restore requires recovery: " + error.payload.what_failed,
|
|
594
|
+
production_changed=context.database_replaced,
|
|
595
|
+
previous_application_running=context.selected_running,
|
|
596
|
+
log_path=context.operation_log,
|
|
597
|
+
next_safe_action=(
|
|
598
|
+
"Keep the recorded database and applications unchanged. Determine the live "
|
|
599
|
+
"traffic and maintenance state before any database restore."
|
|
600
|
+
),
|
|
601
|
+
)
|
|
602
|
+
_finish_restore_failure(context, recovery)
|
|
603
|
+
raise recovery from None
|
|
604
|
+
try:
|
|
605
|
+
rolled_back = _rollback_manual_restore(context, error)
|
|
606
|
+
except DployDBError as rollback_error:
|
|
607
|
+
_finish_restore_failure(context, rollback_error)
|
|
608
|
+
raise rollback_error from None
|
|
609
|
+
_finish_restore_failure(context, rolled_back)
|
|
610
|
+
raise rolled_back from None
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def _rollback_manual_restore(
|
|
614
|
+
context: _RestoreContext,
|
|
615
|
+
original: DployDBError,
|
|
616
|
+
) -> OperationFailedError:
|
|
617
|
+
try:
|
|
618
|
+
if context.selected_running:
|
|
619
|
+
context.dependencies.production.stop_current(
|
|
620
|
+
context.selection.selected_application,
|
|
621
|
+
cancellation_event=context.cancellation_event,
|
|
622
|
+
)
|
|
623
|
+
context.selected_running = False
|
|
624
|
+
if context.database_replaced:
|
|
625
|
+
if context.pre_restore_backup is None:
|
|
626
|
+
raise RuntimeError("pre-restore backup identity is unavailable")
|
|
627
|
+
restore_verified_database(
|
|
628
|
+
context.pre_restore_backup,
|
|
629
|
+
context.loaded.config.database.path,
|
|
630
|
+
application_stopped=True,
|
|
631
|
+
traffic_activated=False,
|
|
632
|
+
secrets=context.loaded.secrets,
|
|
633
|
+
)
|
|
634
|
+
if context.current_stopped:
|
|
635
|
+
context.dependencies.production.restart_previous(
|
|
636
|
+
context.selection.current_application,
|
|
637
|
+
cancellation_event=context.cancellation_event,
|
|
638
|
+
)
|
|
639
|
+
current_target = context.dependencies.traffic.activate_new(
|
|
640
|
+
cancellation_event=context.cancellation_event
|
|
641
|
+
)
|
|
642
|
+
_record_restore_hook(context, current_target)
|
|
643
|
+
if not current_target.passed:
|
|
644
|
+
raise RuntimeError("current traffic target could not be restored")
|
|
645
|
+
maintenance_off = context.dependencies.traffic.disable_maintenance(
|
|
646
|
+
cancellation_event=context.cancellation_event
|
|
647
|
+
)
|
|
648
|
+
_record_restore_hook(context, maintenance_off)
|
|
649
|
+
if not maintenance_off.passed:
|
|
650
|
+
raise RuntimeError("maintenance could not be disabled after restore rollback")
|
|
651
|
+
context.dependencies.health.check_application(
|
|
652
|
+
version=context.selection.active_release.requested_version,
|
|
653
|
+
database_path=context.loaded.config.database.path,
|
|
654
|
+
cancellation_event=context.cancellation_event,
|
|
655
|
+
)
|
|
656
|
+
verify_sqlite_database(context.loaded.config.database.path)
|
|
657
|
+
except Exception as rollback_error:
|
|
658
|
+
raise RecoveryRequiredError(
|
|
659
|
+
"manual restore failed and the original release could not be proven restored: "
|
|
660
|
+
+ context.loaded.secrets.redact_text(str(rollback_error)),
|
|
661
|
+
production_changed=context.database_replaced,
|
|
662
|
+
previous_application_running=False,
|
|
663
|
+
log_path=context.operation_log,
|
|
664
|
+
next_safe_action=(
|
|
665
|
+
"Keep maintenance enabled and restore the recorded pre-restore backup and active "
|
|
666
|
+
"application manually."
|
|
667
|
+
),
|
|
668
|
+
) from None
|
|
669
|
+
return OperationFailedError(
|
|
670
|
+
"manual restore did not complete; the original database and application were restored "
|
|
671
|
+
"and verified: " + original.payload.what_failed,
|
|
672
|
+
production_changed=context.database_replaced,
|
|
673
|
+
previous_application_running=True,
|
|
674
|
+
log_path=context.operation_log,
|
|
675
|
+
next_safe_action="Inspect the restore log, correct the failure, and request restore again.",
|
|
676
|
+
)
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def _enable_restore_maintenance(context: _RestoreContext) -> None:
|
|
680
|
+
result = context.dependencies.traffic.enable_maintenance(
|
|
681
|
+
cancellation_event=context.cancellation_event
|
|
682
|
+
)
|
|
683
|
+
_record_restore_hook(context, result)
|
|
684
|
+
if not result.passed:
|
|
685
|
+
cleanup = context.dependencies.traffic.disable_maintenance(
|
|
686
|
+
cancellation_event=context.cancellation_event
|
|
687
|
+
)
|
|
688
|
+
_record_restore_hook(context, cleanup)
|
|
689
|
+
if not cleanup.passed:
|
|
690
|
+
raise RecoveryRequiredError(
|
|
691
|
+
"maintenance enable failed and cleanup could not be proven",
|
|
692
|
+
production_changed=False,
|
|
693
|
+
previous_application_running=True,
|
|
694
|
+
log_path=context.operation_log,
|
|
695
|
+
next_safe_action="Keep the current application running and disable maintenance.",
|
|
696
|
+
)
|
|
697
|
+
raise _restore_hook_error(result, context)
|
|
698
|
+
context.maintenance_enabled = True
|
|
699
|
+
context.store.transition(
|
|
700
|
+
context.operation_id,
|
|
701
|
+
status=OperationStatus.IN_PROGRESS,
|
|
702
|
+
stage="manual_restore_maintenance_enabled",
|
|
703
|
+
message="Maintenance mode is enabled before manual restore.",
|
|
704
|
+
evidence={"traffic_hook": result.as_evidence()},
|
|
705
|
+
safety=SafetyFacts(
|
|
706
|
+
production_changed=False,
|
|
707
|
+
previous_application_running=True,
|
|
708
|
+
recovery_required=False,
|
|
709
|
+
),
|
|
710
|
+
)
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def _record_restore_hook(context: _RestoreContext, result: TrafficHookResult) -> None:
|
|
714
|
+
context.store.append_event(
|
|
715
|
+
context.operation_id,
|
|
716
|
+
message=f"Manual restore traffic hook {result.action.value} reached a terminal outcome.",
|
|
717
|
+
evidence={
|
|
718
|
+
"traffic_hook": result.as_evidence(),
|
|
719
|
+
"hook_summary": hook_summary(result).model_dump(mode="json"),
|
|
720
|
+
},
|
|
721
|
+
)
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def _restore_hook_error(
|
|
725
|
+
result: TrafficHookResult,
|
|
726
|
+
context: _RestoreContext,
|
|
727
|
+
*,
|
|
728
|
+
recovery: bool = False,
|
|
729
|
+
) -> DployDBError:
|
|
730
|
+
error_type: type[DployDBError] = (
|
|
731
|
+
RecoveryRequiredError
|
|
732
|
+
if recovery or result.command.outcome is CommandOutcome.CLEANUP_FAILED
|
|
733
|
+
else ExternalCommandError
|
|
734
|
+
)
|
|
735
|
+
return error_type(
|
|
736
|
+
f"manual restore traffic hook {result.action.value} ended {result.command.outcome.value}",
|
|
737
|
+
production_changed=context.database_replaced,
|
|
738
|
+
previous_application_running=context.selected_running,
|
|
739
|
+
log_path=context.operation_log,
|
|
740
|
+
next_safe_action="Keep maintenance enabled and inspect the recorded hook evidence.",
|
|
741
|
+
)
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
def _hook_may_have_run(result: TrafficHookResult) -> bool:
|
|
745
|
+
return not (
|
|
746
|
+
result.command.outcome is CommandOutcome.START_FAILED
|
|
747
|
+
or (result.command.outcome is CommandOutcome.CANCELLED and result.command.exit_code is None)
|
|
748
|
+
)
|
|
749
|
+
|
|
750
|
+
|
|
751
|
+
def _normalize_restore_coordinator_error(
|
|
752
|
+
error: BaseException,
|
|
753
|
+
context: _RestoreContext,
|
|
754
|
+
) -> DployDBError | None:
|
|
755
|
+
if isinstance(error, DployDBError):
|
|
756
|
+
return error
|
|
757
|
+
if not isinstance(error, Exception):
|
|
758
|
+
return None
|
|
759
|
+
if context.current_stop_attempted and not context.current_stopped:
|
|
760
|
+
return RecoveryRequiredError(
|
|
761
|
+
"the current application stop was attempted without durable stopped proof",
|
|
762
|
+
production_changed=False,
|
|
763
|
+
previous_application_running=None,
|
|
764
|
+
log_path=context.operation_log,
|
|
765
|
+
next_safe_action="Keep maintenance enabled and inspect the exact current container.",
|
|
766
|
+
)
|
|
767
|
+
if context.selected_restart_attempted and not context.selected_running:
|
|
768
|
+
return RecoveryRequiredError(
|
|
769
|
+
"the selected application restart was attempted without durable running proof",
|
|
770
|
+
production_changed=context.database_replaced,
|
|
771
|
+
previous_application_running=None,
|
|
772
|
+
log_path=context.operation_log,
|
|
773
|
+
next_safe_action=(
|
|
774
|
+
"Keep maintenance enabled and inspect both exact containers before database "
|
|
775
|
+
"rollback."
|
|
776
|
+
),
|
|
777
|
+
)
|
|
778
|
+
return OperationFailedError(
|
|
779
|
+
"manual restore boundary failed: "
|
|
780
|
+
+ context.loaded.secrets.redact_text(f"{type(error).__name__}: {error}"),
|
|
781
|
+
production_changed=context.database_replaced,
|
|
782
|
+
previous_application_running=(False if context.current_stopped else True),
|
|
783
|
+
log_path=context.operation_log,
|
|
784
|
+
next_safe_action="Keep maintenance enabled until the original release is restored.",
|
|
785
|
+
)
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
def _finish_restore_failure(context: _RestoreContext, error: DployDBError) -> None:
|
|
789
|
+
current = context.store.read_manifest(context.operation_id)
|
|
790
|
+
if current.status is not OperationStatus.IN_PROGRESS:
|
|
791
|
+
return
|
|
792
|
+
status = (
|
|
793
|
+
OperationStatus.RECOVERY_REQUIRED
|
|
794
|
+
if error.payload.recovery_required
|
|
795
|
+
else OperationStatus.FAILED_SAFE
|
|
796
|
+
)
|
|
797
|
+
context.store.transition(
|
|
798
|
+
context.operation_id,
|
|
799
|
+
status=status,
|
|
800
|
+
stage=status.value,
|
|
801
|
+
message="Manual restore did not complete.",
|
|
802
|
+
safety=SafetyFacts(
|
|
803
|
+
production_changed=error.payload.production_changed,
|
|
804
|
+
previous_application_running=error.payload.previous_application_running,
|
|
805
|
+
recovery_required=error.payload.recovery_required,
|
|
806
|
+
),
|
|
807
|
+
failure=FailureRecord(
|
|
808
|
+
error_code=error.payload.error_code,
|
|
809
|
+
what_failed=error.payload.what_failed,
|
|
810
|
+
log_path=error.payload.log_path,
|
|
811
|
+
next_safe_action=error.payload.next_safe_action,
|
|
812
|
+
),
|
|
813
|
+
)
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
def _manual_dependencies(dependencies: DeploymentDependencies) -> ManualRestoreDependencies:
|
|
817
|
+
return ManualRestoreDependencies(
|
|
818
|
+
production=dependencies.production,
|
|
819
|
+
traffic=dependencies.traffic,
|
|
820
|
+
health=dependencies.health,
|
|
821
|
+
)
|
|
822
|
+
|
|
823
|
+
|
|
824
|
+
def _no_fault(_stage: str) -> None:
|
|
825
|
+
return
|