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.
@@ -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