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/recovery.py ADDED
@@ -0,0 +1,1210 @@
1
+ """Crash-safe recovery diagnosis and execution planning."""
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 enum import StrEnum
10
+ from pathlib import Path
11
+ from typing import Any, Protocol
12
+
13
+ from dploydb.backup import calculate_sha256, open_verified_configured_backup
14
+ from dploydb.config import (
15
+ LoadedConfiguration,
16
+ configuration_fingerprint,
17
+ require_deploy_topology,
18
+ )
19
+ from dploydb.deployment_dependencies import (
20
+ DeploymentDependencies,
21
+ ProductionHealthBoundary,
22
+ default_dependencies,
23
+ )
24
+ from dploydb.deployment_evidence import cleanup_evidence, hook_summary, restart_evidence
25
+ from dploydb.errors import (
26
+ DployDBError,
27
+ LockUnavailableError,
28
+ RecoveryRequiredError,
29
+ SafetyCheckError,
30
+ )
31
+ from dploydb.health import ApplicationHealthChecker
32
+ from dploydb.locking import DeploymentLock, LockInspectionState, inspect_lock
33
+ from dploydb.models import (
34
+ DeploymentState,
35
+ FailureRecord,
36
+ LockOwnerState,
37
+ OperationEvent,
38
+ OperationManifest,
39
+ OperationStatus,
40
+ ProductionApplicationHandle,
41
+ ReleaseManifest,
42
+ SafetyFacts,
43
+ )
44
+ from dploydb.releases import ReleaseStore
45
+ from dploydb.restore import restore_verified_database
46
+ from dploydb.runners.base import (
47
+ ProductionApplicationRunner,
48
+ ProductionCleanupProof,
49
+ ProductionInspection,
50
+ ProductionInspectionError,
51
+ )
52
+ from dploydb.sqlite_checks import verify_sqlite_database
53
+ from dploydb.state import StateStore
54
+ from dploydb.storage.base import RemoteBackupStorage
55
+ from dploydb.traffic import TrafficController, TrafficHookResult
56
+
57
+
58
+ class ApplicationRuntimeState(StrEnum):
59
+ """Live state of one exact recorded production application."""
60
+
61
+ RUNNING = "running"
62
+ STOPPED = "stopped"
63
+ ABSENT = "absent"
64
+ UNKNOWN = "unknown"
65
+
66
+
67
+ class RecoveryDisposition(StrEnum):
68
+ """Top-level decision produced before any recovery mutation."""
69
+
70
+ NO_ACTION = "no_action"
71
+ RECOVER_PREVIOUS = "recover_previous"
72
+ COMPLETE_NEW = "complete_new"
73
+ MANUAL_REQUIRED = "manual_required"
74
+
75
+
76
+ class RecoveryAction(StrEnum):
77
+ """Ordered, individually provable recovery actions."""
78
+
79
+ REMOVE_NEW_APPLICATION = "remove_new_application"
80
+ RESTORE_FINAL_BACKUP = "restore_final_backup"
81
+ RESTART_PREVIOUS_APPLICATION = "restart_previous_application"
82
+ ACTIVATE_PREVIOUS_TRAFFIC = "activate_previous_traffic"
83
+ DISABLE_MAINTENANCE = "disable_maintenance"
84
+ VERIFY_PREVIOUS = "verify_previous"
85
+ MARK_ROLLED_BACK = "mark_rolled_back"
86
+ VERIFY_NEW = "verify_new"
87
+ MARK_ACTIVE = "mark_active"
88
+
89
+
90
+ @dataclass(frozen=True, slots=True)
91
+ class RecoveryLiveState:
92
+ """Read-only facts collected from exact live resources and verified files."""
93
+
94
+ previous_application: ApplicationRuntimeState
95
+ new_application: ApplicationRuntimeState
96
+ final_backup_verified: bool
97
+ production_database_sha256: str | None
98
+ final_backup_sha256: str | None
99
+
100
+
101
+ @dataclass(frozen=True, slots=True)
102
+ class RecoveryPlan:
103
+ """A deterministic plan that authorizes no action outside its ordered list."""
104
+
105
+ disposition: RecoveryDisposition
106
+ release_id: str
107
+ operation_id: str
108
+ durable_stage: str
109
+ production_may_have_changed: bool
110
+ traffic_may_have_switched: bool
111
+ automatic_database_restore_allowed: bool
112
+ actions: tuple[RecoveryAction, ...]
113
+ reason: str
114
+ next_safe_action: str
115
+ final_backup_id: str | None
116
+
117
+ @property
118
+ def executable(self) -> bool:
119
+ return self.disposition in {
120
+ RecoveryDisposition.RECOVER_PREVIOUS,
121
+ RecoveryDisposition.COMPLETE_NEW,
122
+ }
123
+
124
+ def as_dict(self) -> dict[str, object]:
125
+ return {
126
+ "disposition": self.disposition.value,
127
+ "release_id": self.release_id,
128
+ "operation_id": self.operation_id,
129
+ "durable_stage": self.durable_stage,
130
+ "production_may_have_changed": self.production_may_have_changed,
131
+ "traffic_may_have_switched": self.traffic_may_have_switched,
132
+ "automatic_database_restore_allowed": self.automatic_database_restore_allowed,
133
+ "actions": [action.value for action in self.actions],
134
+ "reason": self.reason,
135
+ "next_safe_action": self.next_safe_action,
136
+ "final_backup_id": self.final_backup_id,
137
+ }
138
+
139
+
140
+ class RecoveryApplicationInspector(Protocol):
141
+ """Read-only application inspection needed before planning recovery."""
142
+
143
+ def inspect_live(
144
+ self,
145
+ handle: ProductionApplicationHandle,
146
+ ) -> ProductionInspection: ...
147
+
148
+ def prove_release_absent(
149
+ self,
150
+ *,
151
+ release_id: str,
152
+ version: str,
153
+ ) -> ProductionCleanupProof: ...
154
+
155
+
156
+ @dataclass(frozen=True, slots=True)
157
+ class RecoveryDependencies:
158
+ """Operational boundaries used after the read-only plan authorizes recovery."""
159
+
160
+ production: ProductionApplicationRunner
161
+ traffic: TrafficController
162
+ health: ProductionHealthBoundary
163
+
164
+
165
+ @dataclass(frozen=True, slots=True)
166
+ class RecoveryResult:
167
+ """Proven terminal result of an executed recovery plan."""
168
+
169
+ plan: RecoveryPlan
170
+ operation: OperationManifest
171
+ release: ReleaseManifest
172
+ operation_log_path: Path
173
+
174
+ def as_dict(self) -> dict[str, object]:
175
+ return {
176
+ "ok": True,
177
+ "command": "recover",
178
+ "outcome": (
179
+ "active" if self.release.status is DeploymentState.ACTIVE else "rolled_back"
180
+ ),
181
+ "recovery_operation_id": self.operation.operation_id,
182
+ "source_operation_id": self.plan.operation_id,
183
+ "release_id": self.release.release_id,
184
+ "release_status": self.release.status.value,
185
+ "production_changed": self.operation.safety.production_changed,
186
+ "previous_application_running": (self.operation.safety.previous_application_running),
187
+ "recovery_required": False,
188
+ "actions": [action.value for action in self.plan.actions],
189
+ "log_path": str(self.operation_log_path),
190
+ }
191
+
192
+
193
+ FaultInjector = Callable[[str], None]
194
+
195
+
196
+ def preview_configured_recovery(
197
+ loaded: LoadedConfiguration,
198
+ *,
199
+ config_path: Path,
200
+ command_environment: Mapping[str, str] | None = None,
201
+ dependencies: RecoveryDependencies | None = None,
202
+ remote_storage: RemoteBackupStorage | None = None,
203
+ ) -> RecoveryPlan:
204
+ """Build a live recovery plan without acquiring a mutating lock or writing state."""
205
+ environment = dict(os.environ if command_environment is None else command_environment)
206
+ selected, owned_health = _configured_recovery_dependencies(
207
+ loaded,
208
+ config_path=config_path,
209
+ command_environment=environment,
210
+ dependencies=dependencies,
211
+ )
212
+ try:
213
+ return diagnose_configured_recovery(
214
+ loaded,
215
+ application_inspector=selected.production,
216
+ environment=environment,
217
+ remote_storage=remote_storage,
218
+ )
219
+ finally:
220
+ if owned_health is not None:
221
+ owned_health.close()
222
+
223
+
224
+ def recover_configured_deployment(
225
+ loaded: LoadedConfiguration,
226
+ *,
227
+ config_path: Path,
228
+ command_environment: Mapping[str, str] | None = None,
229
+ dependencies: RecoveryDependencies | None = None,
230
+ remote_storage: RemoteBackupStorage | None = None,
231
+ cancellation_event: threading.Event | None = None,
232
+ fault_injector: FaultInjector | None = None,
233
+ ) -> RecoveryResult:
234
+ """Execute only a freshly revalidated, deterministic recovery plan."""
235
+ environment = dict(os.environ if command_environment is None else command_environment)
236
+ selected, owned_health = _configured_recovery_dependencies(
237
+ loaded,
238
+ config_path=config_path,
239
+ command_environment=environment,
240
+ dependencies=dependencies,
241
+ )
242
+ store = StateStore(loaded.config.state_directory, secrets=loaded.secrets)
243
+ releases = ReleaseStore(loaded.config.state_directory, secrets=loaded.secrets)
244
+ lock = DeploymentLock(loaded.config.state_directory, secrets=loaded.secrets)
245
+ inject = fault_injector or _no_fault
246
+ try:
247
+ with lock:
248
+ plan = diagnose_configured_recovery(
249
+ loaded,
250
+ application_inspector=selected.production,
251
+ check_lock=False,
252
+ environment=environment,
253
+ remote_storage=remote_storage,
254
+ )
255
+ if not plan.executable:
256
+ raise RecoveryRequiredError(
257
+ plan.reason,
258
+ production_changed=plan.production_may_have_changed,
259
+ previous_application_running=None,
260
+ log_path=store.operation_paths(plan.operation_id).events,
261
+ next_safe_action=plan.next_safe_action,
262
+ )
263
+ release = releases.read_manifest(plan.release_id)
264
+ stale_owner_id = _acknowledged_stale_owner_id(lock, store, plan)
265
+ _take_recovery_ownership(store, releases, plan, release)
266
+ operation = store.create_operation(
267
+ operation_type="recover",
268
+ project=loaded.config.project,
269
+ configuration_fingerprint=configuration_fingerprint(
270
+ loaded.config, secrets=loaded.secrets
271
+ ),
272
+ stage="recovery_started",
273
+ evidence={"source_plan": plan.as_dict()},
274
+ )
275
+ lock.record_owner(
276
+ operation_id=operation.operation_id,
277
+ operation_type="recover",
278
+ replace_stale_owner_id=stale_owner_id,
279
+ )
280
+ operation_log = store.operation_paths(operation.operation_id).events
281
+ try:
282
+ return _execute_recovery_plan(
283
+ loaded,
284
+ plan=plan,
285
+ operation_id=operation.operation_id,
286
+ store=store,
287
+ releases=releases,
288
+ dependencies=selected,
289
+ operation_log=operation_log,
290
+ cancellation_event=cancellation_event,
291
+ inject=inject,
292
+ environment=environment,
293
+ remote_storage=remote_storage,
294
+ )
295
+ except BaseException as raw_error:
296
+ error = _normalize_recovery_error(raw_error, plan, operation_log, loaded)
297
+ if error is None:
298
+ raise
299
+ _finish_recovery_operation(store, operation.operation_id, error)
300
+ raise error from None
301
+ finally:
302
+ if owned_health is not None:
303
+ owned_health.close()
304
+
305
+
306
+ def diagnose_configured_recovery(
307
+ loaded: LoadedConfiguration,
308
+ *,
309
+ application_inspector: RecoveryApplicationInspector,
310
+ check_lock: bool = True,
311
+ environment: Mapping[str, str] | None = None,
312
+ remote_storage: RemoteBackupStorage | None = None,
313
+ ) -> RecoveryPlan:
314
+ """Read all durable/live evidence and return a plan without changing state."""
315
+ state_directory = loaded.config.state_directory
316
+ if check_lock:
317
+ lock = inspect_lock(state_directory, secrets=loaded.secrets)
318
+ if lock.state is LockInspectionState.ACTIVE:
319
+ raise LockUnavailableError(
320
+ "another DployDB operation still holds the deployment lock",
321
+ production_changed=False,
322
+ previous_application_running=None,
323
+ log_path=lock.owner_path,
324
+ next_safe_action=(
325
+ "Wait for the active operation, then run recovery diagnosis again."
326
+ ),
327
+ )
328
+ if lock.state is LockInspectionState.RECOVERY_REQUIRED:
329
+ raise RecoveryRequiredError(
330
+ lock.metadata_error or "deployment lock evidence is contradictory",
331
+ production_changed=True,
332
+ previous_application_running=None,
333
+ log_path=lock.owner_path,
334
+ next_safe_action="Preserve the lock evidence and inspect the host manually.",
335
+ )
336
+
337
+ store = StateStore(state_directory, secrets=loaded.secrets)
338
+ operations = store.list_operations()
339
+ if not operations:
340
+ raise SafetyCheckError(
341
+ "there is no deployment operation to recover",
342
+ production_changed=False,
343
+ previous_application_running=None,
344
+ log_path=state_directory,
345
+ next_safe_action="Run dploydb status; no recovery action is required.",
346
+ )
347
+ history = ReleaseStore(state_directory, secrets=loaded.secrets).read_history()
348
+ operations_by_id = {item.operation_id: item for item in operations}
349
+ incomplete_resolution = next(
350
+ (
351
+ (release, operations_by_id[release.recovery_operation_id])
352
+ for release in history.releases
353
+ if release.recovery_operation_id is not None
354
+ and release.recovery_operation_id in operations_by_id
355
+ and operations_by_id[release.recovery_operation_id].status
356
+ is not OperationStatus.SUCCEEDED
357
+ ),
358
+ None,
359
+ )
360
+ if incomplete_resolution is not None:
361
+ resolved_release, recovery_operation = incomplete_resolution
362
+ raise RecoveryRequiredError(
363
+ "release recovery was resolved but its recovery operation is not durably complete",
364
+ production_changed=resolved_release.production_changed,
365
+ previous_application_running=(resolved_release.status is DeploymentState.ROLLED_BACK),
366
+ log_path=store.operation_paths(recovery_operation.operation_id).events,
367
+ next_safe_action=(
368
+ "Do not repeat database actions; preserve both records and reconcile the "
369
+ "recovery operation manually."
370
+ ),
371
+ )
372
+ unresolved = [
373
+ item
374
+ for item in history.releases
375
+ if item.status
376
+ not in {
377
+ DeploymentState.ACTIVE,
378
+ DeploymentState.ROLLED_BACK,
379
+ DeploymentState.FAILED_SAFE,
380
+ }
381
+ and item.operation_id in operations_by_id
382
+ ]
383
+ if len(unresolved) > 1:
384
+ raise RecoveryRequiredError(
385
+ "multiple unresolved releases make automatic recovery ambiguous",
386
+ production_changed=True,
387
+ previous_application_running=None,
388
+ log_path=state_directory,
389
+ next_safe_action="Preserve state and correlate the operations and releases manually.",
390
+ )
391
+ if unresolved:
392
+ release = unresolved[0]
393
+ operation = operations_by_id[release.operation_id]
394
+ else:
395
+ paired = [
396
+ (item, operations_by_id[item.operation_id])
397
+ for item in history.releases
398
+ if item.operation_id in operations_by_id
399
+ ]
400
+ if not paired:
401
+ raise RecoveryRequiredError(
402
+ "no deployment operation maps to a release manifest",
403
+ production_changed=False,
404
+ previous_application_running=None,
405
+ log_path=state_directory,
406
+ next_safe_action="Preserve state and correlate operation history manually.",
407
+ )
408
+ release, operation = max(
409
+ paired,
410
+ key=lambda item: (item[1].started_at, item[1].operation_id),
411
+ )
412
+ events = store.read_events(operation.operation_id)
413
+ previous_state = _inspect_application(application_inspector, release.previous_application)
414
+ new_state = _inspect_new_application(application_inspector, release)
415
+ final_verified = False
416
+ final_sha256: str | None = None
417
+ if release.final_backup_id is not None:
418
+ try:
419
+ with open_verified_configured_backup(
420
+ loaded,
421
+ release.final_backup_id,
422
+ environment=environment,
423
+ remote_storage=remote_storage,
424
+ ) as artifact:
425
+ final_verified = artifact.metadata.sha256 == release.final_backup_sha256
426
+ final_sha256 = artifact.metadata.sha256
427
+ except Exception:
428
+ pass
429
+ try:
430
+ _size_bytes, production_sha256 = calculate_sha256(loaded.config.database.path)
431
+ except Exception:
432
+ production_sha256 = None
433
+ return build_recovery_plan(
434
+ operation,
435
+ events,
436
+ release,
437
+ RecoveryLiveState(
438
+ previous_application=previous_state,
439
+ new_application=new_state,
440
+ final_backup_verified=final_verified,
441
+ production_database_sha256=production_sha256,
442
+ final_backup_sha256=final_sha256,
443
+ ),
444
+ )
445
+
446
+
447
+ def _inspect_application(
448
+ inspector: RecoveryApplicationInspector,
449
+ handle: ProductionApplicationHandle | None,
450
+ ) -> ApplicationRuntimeState:
451
+ if handle is None:
452
+ return ApplicationRuntimeState.UNKNOWN
453
+ try:
454
+ inspection = inspector.inspect_live(handle)
455
+ except ProductionInspectionError:
456
+ return ApplicationRuntimeState.UNKNOWN
457
+ return (
458
+ ApplicationRuntimeState.RUNNING if inspection.running else ApplicationRuntimeState.STOPPED
459
+ )
460
+
461
+
462
+ def _inspect_new_application(
463
+ inspector: RecoveryApplicationInspector,
464
+ release: ReleaseManifest,
465
+ ) -> ApplicationRuntimeState:
466
+ if release.new_application is not None:
467
+ state = _inspect_application(inspector, release.new_application)
468
+ if state is not ApplicationRuntimeState.UNKNOWN:
469
+ return state
470
+ try:
471
+ proof = inspector.prove_release_absent(
472
+ release_id=release.release_id,
473
+ version=release.requested_version,
474
+ )
475
+ except Exception:
476
+ return ApplicationRuntimeState.UNKNOWN
477
+ return ApplicationRuntimeState.ABSENT if proof.proven else ApplicationRuntimeState.UNKNOWN
478
+
479
+
480
+ def _configured_recovery_dependencies(
481
+ loaded: LoadedConfiguration,
482
+ *,
483
+ config_path: Path,
484
+ command_environment: Mapping[str, str] | None,
485
+ dependencies: RecoveryDependencies | None,
486
+ ) -> tuple[RecoveryDependencies, ApplicationHealthChecker | None]:
487
+ if dependencies is not None:
488
+ return dependencies, None
489
+ topology = require_deploy_topology(loaded.config)
490
+ environment = dict(os.environ if command_environment is None else command_environment)
491
+ configured, health = default_dependencies(
492
+ loaded,
493
+ topology=topology,
494
+ config_path=config_path,
495
+ working_directory=config_path.resolve().parent,
496
+ command_environment=environment,
497
+ candidate_command_runner=None,
498
+ candidate_application_runner=None,
499
+ candidate_health_checker=None,
500
+ )
501
+ return _recovery_dependencies(configured), health
502
+
503
+
504
+ def _recovery_dependencies(dependencies: DeploymentDependencies) -> RecoveryDependencies:
505
+ return RecoveryDependencies(
506
+ production=dependencies.production,
507
+ traffic=dependencies.traffic,
508
+ health=dependencies.health,
509
+ )
510
+
511
+
512
+ def _acknowledged_stale_owner_id(
513
+ lock: DeploymentLock,
514
+ store: StateStore,
515
+ plan: RecoveryPlan,
516
+ ) -> str | None:
517
+ """Authorize replacing only the dead owner of this exact recovery lineage."""
518
+ owner = lock.previous_owner
519
+ if owner is None or owner.state is LockOwnerState.RELEASED:
520
+ return None
521
+ if owner.operation_id == plan.operation_id:
522
+ return owner.owner_id
523
+
524
+ try:
525
+ owner_operation = store.read_manifest(owner.operation_id)
526
+ owner_events = store.read_events(owner.operation_id)
527
+ except DployDBError as exc:
528
+ raise RecoveryRequiredError(
529
+ "stale deployment-lock ownership cannot be correlated to durable state",
530
+ production_changed=plan.production_may_have_changed,
531
+ previous_application_running=None,
532
+ log_path=lock.owner_path,
533
+ next_safe_action="Preserve the lock metadata and operation history for inspection.",
534
+ ) from exc
535
+ source_plan = owner_events[0].evidence.get("source_plan") if owner_events else None
536
+ related_recovery = (
537
+ owner_operation.operation_type == "recover"
538
+ and owner_operation.status is OperationStatus.IN_PROGRESS
539
+ and isinstance(source_plan, dict)
540
+ and source_plan.get("operation_id") == plan.operation_id
541
+ and source_plan.get("release_id") == plan.release_id
542
+ )
543
+ if related_recovery:
544
+ return owner.owner_id
545
+ raise RecoveryRequiredError(
546
+ "stale deployment-lock ownership belongs to a different operation",
547
+ production_changed=plan.production_may_have_changed,
548
+ previous_application_running=None,
549
+ log_path=lock.owner_path,
550
+ next_safe_action=(
551
+ "Do not replace the owner token; reconcile the recorded lock and operations."
552
+ ),
553
+ )
554
+
555
+
556
+ def _take_recovery_ownership(
557
+ store: StateStore,
558
+ releases: ReleaseStore,
559
+ plan: RecoveryPlan,
560
+ release: ReleaseManifest,
561
+ ) -> None:
562
+ failure = FailureRecord(
563
+ error_code="recovery_required",
564
+ what_failed="The prior operation was interrupted and recovery took ownership.",
565
+ log_path=str(store.operation_paths(plan.operation_id).events),
566
+ next_safe_action="Continue only through the recorded recovery plan.",
567
+ )
568
+ for operation in store.list_operations():
569
+ if operation.status is not OperationStatus.IN_PROGRESS:
570
+ continue
571
+ if operation.operation_id != plan.operation_id and operation.operation_type != "recover":
572
+ raise RecoveryRequiredError(
573
+ "an unrelated unfinished operation blocks automatic recovery",
574
+ production_changed=plan.production_may_have_changed,
575
+ previous_application_running=None,
576
+ log_path=store.operation_paths(operation.operation_id).events,
577
+ next_safe_action="Resolve the unrelated operation before deployment recovery.",
578
+ )
579
+ store.transition(
580
+ operation.operation_id,
581
+ status=OperationStatus.RECOVERY_REQUIRED,
582
+ stage="recovery_required",
583
+ message="An interrupted operation was superseded by explicit recovery.",
584
+ safety=SafetyFacts(
585
+ production_changed=(
586
+ operation.safety.production_changed or plan.production_may_have_changed
587
+ ),
588
+ previous_application_running=None,
589
+ recovery_required=True,
590
+ ),
591
+ failure=failure,
592
+ )
593
+ if release.status is not DeploymentState.RECOVERY_REQUIRED:
594
+ releases.transition(
595
+ release.release_id,
596
+ status=DeploymentState.RECOVERY_REQUIRED,
597
+ production_changed=plan.production_may_have_changed,
598
+ traffic_activated=(plan.disposition is RecoveryDisposition.COMPLETE_NEW),
599
+ failure=failure,
600
+ )
601
+
602
+
603
+ def _execute_recovery_plan(
604
+ loaded: LoadedConfiguration,
605
+ *,
606
+ plan: RecoveryPlan,
607
+ operation_id: str,
608
+ store: StateStore,
609
+ releases: ReleaseStore,
610
+ dependencies: RecoveryDependencies,
611
+ operation_log: Path,
612
+ cancellation_event: threading.Event | None,
613
+ inject: FaultInjector,
614
+ environment: Mapping[str, str],
615
+ remote_storage: RemoteBackupStorage | None,
616
+ ) -> RecoveryResult:
617
+ release = releases.read_manifest(plan.release_id)
618
+ previous = release.previous_application
619
+ new = release.new_application
620
+ for action in plan.actions:
621
+ store.transition(
622
+ operation_id,
623
+ status=OperationStatus.IN_PROGRESS,
624
+ stage=f"recovery_{action.value}",
625
+ message=f"Recovery action {action.value} is starting.",
626
+ evidence={"action": action.value},
627
+ safety=SafetyFacts(
628
+ production_changed=plan.production_may_have_changed,
629
+ previous_application_running=None,
630
+ recovery_required=False,
631
+ ),
632
+ )
633
+ if action is RecoveryAction.REMOVE_NEW_APPLICATION:
634
+ if new is None:
635
+ raise RecoveryRequiredError(
636
+ "recovery plan requires a missing new-application handle",
637
+ production_changed=plan.production_may_have_changed,
638
+ previous_application_running=False,
639
+ log_path=operation_log,
640
+ next_safe_action="Keep traffic blocked and inspect release resources.",
641
+ )
642
+ cleanup = dependencies.production.remove_new(new)
643
+ store.append_event(
644
+ operation_id,
645
+ message="New-release cleanup was proven during recovery.",
646
+ evidence={"production_cleanup": cleanup_evidence(cleanup)},
647
+ )
648
+ elif action is RecoveryAction.RESTORE_FINAL_BACKUP:
649
+ _execute_database_recovery(
650
+ loaded,
651
+ release=release,
652
+ previous=previous,
653
+ dependencies=dependencies,
654
+ operation_id=operation_id,
655
+ store=store,
656
+ operation_log=operation_log,
657
+ cancellation_event=cancellation_event,
658
+ environment=environment,
659
+ remote_storage=remote_storage,
660
+ )
661
+ elif action is RecoveryAction.RESTART_PREVIOUS_APPLICATION:
662
+ if previous is None:
663
+ raise RecoveryRequiredError(
664
+ "recovery previous-application identity is missing",
665
+ production_changed=plan.production_may_have_changed,
666
+ previous_application_running=None,
667
+ log_path=operation_log,
668
+ next_safe_action="Keep maintenance enabled and inspect production.",
669
+ )
670
+ restarted = dependencies.production.restart_previous(
671
+ previous,
672
+ cancellation_event=cancellation_event,
673
+ )
674
+ store.append_event(
675
+ operation_id,
676
+ message="The exact previous application was restarted during recovery.",
677
+ evidence={"previous_restart": restart_evidence(restarted)},
678
+ )
679
+ elif action is RecoveryAction.ACTIVATE_PREVIOUS_TRAFFIC:
680
+ hook = dependencies.traffic.activate_old(cancellation_event=cancellation_event)
681
+ _record_recovery_hook(store, operation_id, hook)
682
+ _require_recovery_hook(hook, operation_log, plan)
683
+ elif action is RecoveryAction.DISABLE_MAINTENANCE:
684
+ hook = dependencies.traffic.disable_maintenance(cancellation_event=cancellation_event)
685
+ _record_recovery_hook(store, operation_id, hook)
686
+ _require_recovery_hook(hook, operation_log, plan)
687
+ elif action is RecoveryAction.VERIFY_PREVIOUS:
688
+ _verify_recovery_application(
689
+ loaded,
690
+ handle=previous,
691
+ version=(None if previous is None else previous.version) or "previous",
692
+ dependencies=dependencies,
693
+ operation_id=operation_id,
694
+ store=store,
695
+ operation_log=operation_log,
696
+ cancellation_event=cancellation_event,
697
+ role="previous",
698
+ )
699
+ elif action is RecoveryAction.VERIFY_NEW:
700
+ if new is not None:
701
+ dependencies.production.inspect(
702
+ new,
703
+ expected_running=True,
704
+ cancellation_event=cancellation_event,
705
+ )
706
+ _verify_recovery_application(
707
+ loaded,
708
+ handle=new,
709
+ version=release.requested_version,
710
+ dependencies=dependencies,
711
+ operation_id=operation_id,
712
+ store=store,
713
+ operation_log=operation_log,
714
+ cancellation_event=cancellation_event,
715
+ role="new",
716
+ )
717
+ elif action in {RecoveryAction.MARK_ROLLED_BACK, RecoveryAction.MARK_ACTIVE}:
718
+ return _complete_recovery(
719
+ plan,
720
+ action=action,
721
+ operation_id=operation_id,
722
+ store=store,
723
+ releases=releases,
724
+ release=release,
725
+ operation_log=operation_log,
726
+ )
727
+ inject(f"after_{action.value}")
728
+ raise RecoveryRequiredError(
729
+ "recovery plan ended without a terminal marking action",
730
+ production_changed=plan.production_may_have_changed,
731
+ previous_application_running=None,
732
+ log_path=operation_log,
733
+ next_safe_action="Preserve recovery evidence and inspect the plan.",
734
+ )
735
+
736
+
737
+ def _execute_database_recovery(
738
+ loaded: LoadedConfiguration,
739
+ *,
740
+ release: ReleaseManifest,
741
+ previous: ProductionApplicationHandle | None,
742
+ dependencies: RecoveryDependencies,
743
+ operation_id: str,
744
+ store: StateStore,
745
+ operation_log: Path,
746
+ cancellation_event: threading.Event | None,
747
+ environment: Mapping[str, str],
748
+ remote_storage: RemoteBackupStorage | None,
749
+ ) -> None:
750
+ if previous is None or release.final_backup_id is None:
751
+ raise RecoveryRequiredError(
752
+ "recovery database prerequisites are incomplete",
753
+ production_changed=True,
754
+ previous_application_running=None,
755
+ log_path=operation_log,
756
+ next_safe_action="Keep every application stopped and inspect the final backup.",
757
+ )
758
+ dependencies.production.inspect(
759
+ previous,
760
+ expected_running=False,
761
+ cancellation_event=cancellation_event,
762
+ )
763
+ with open_verified_configured_backup(
764
+ loaded,
765
+ release.final_backup_id,
766
+ environment=environment,
767
+ remote_storage=remote_storage,
768
+ ) as artifact:
769
+ if artifact.metadata.sha256 != release.final_backup_sha256:
770
+ raise RecoveryRequiredError(
771
+ "recovery final backup checksum contradicts the release manifest",
772
+ production_changed=True,
773
+ previous_application_running=False,
774
+ log_path=artifact.metadata_path,
775
+ next_safe_action="Do not restore the contradictory backup.",
776
+ )
777
+ restored = restore_verified_database(
778
+ artifact,
779
+ loaded.config.database.path,
780
+ application_stopped=True,
781
+ traffic_activated=False,
782
+ secrets=loaded.secrets,
783
+ )
784
+ store.append_event(
785
+ operation_id,
786
+ message="The verified final backup was restored during recovery.",
787
+ evidence={"database_restore": restored.model_dump(mode="json")},
788
+ )
789
+
790
+
791
+ def _verify_recovery_application(
792
+ loaded: LoadedConfiguration,
793
+ *,
794
+ handle: ProductionApplicationHandle | None,
795
+ version: str,
796
+ dependencies: RecoveryDependencies,
797
+ operation_id: str,
798
+ store: StateStore,
799
+ operation_log: Path,
800
+ cancellation_event: threading.Event | None,
801
+ role: str,
802
+ ) -> None:
803
+ if handle is None:
804
+ raise RecoveryRequiredError(
805
+ f"{role} application identity is missing for recovery verification",
806
+ production_changed=True,
807
+ previous_application_running=None,
808
+ log_path=operation_log,
809
+ next_safe_action="Inspect the live production application manually.",
810
+ )
811
+ database = verify_sqlite_database(loaded.config.database.path)
812
+ health = dependencies.health.check_application(
813
+ version=version,
814
+ database_path=loaded.config.database.path,
815
+ cancellation_event=cancellation_event,
816
+ )
817
+ store.append_event(
818
+ operation_id,
819
+ message=f"{role.capitalize()} database and application health passed recovery checks.",
820
+ evidence={
821
+ "database": database.model_dump(mode="json"),
822
+ "application_health": health.as_evidence(),
823
+ },
824
+ )
825
+
826
+
827
+ def _complete_recovery(
828
+ plan: RecoveryPlan,
829
+ *,
830
+ action: RecoveryAction,
831
+ operation_id: str,
832
+ store: StateStore,
833
+ releases: ReleaseStore,
834
+ release: ReleaseManifest,
835
+ operation_log: Path,
836
+ ) -> RecoveryResult:
837
+ active = action is RecoveryAction.MARK_ACTIVE
838
+ resolved = releases.resolve_recovery(
839
+ release.release_id,
840
+ status=(DeploymentState.ACTIVE if active else DeploymentState.ROLLED_BACK),
841
+ recovery_operation_id=operation_id,
842
+ traffic_activated=active,
843
+ )
844
+ evidence: dict[str, object] = {"release_id": resolved.release_id}
845
+ if active:
846
+ pointers = releases.activate_release(resolved.release_id)
847
+ evidence["active_release_id"] = pointers.active_release_id
848
+ operation = store.transition(
849
+ operation_id,
850
+ status=OperationStatus.SUCCEEDED,
851
+ stage="recovered_active" if active else "recovered_rolled_back",
852
+ message=(
853
+ "Recovery verified and activated the new release."
854
+ if active
855
+ else "Recovery restored and verified the previous release."
856
+ ),
857
+ evidence=evidence,
858
+ safety=SafetyFacts(
859
+ production_changed=plan.production_may_have_changed,
860
+ previous_application_running=not active,
861
+ recovery_required=False,
862
+ ),
863
+ )
864
+ return RecoveryResult(
865
+ plan=plan,
866
+ operation=operation,
867
+ release=resolved,
868
+ operation_log_path=operation_log,
869
+ )
870
+
871
+
872
+ def _record_recovery_hook(
873
+ store: StateStore,
874
+ operation_id: str,
875
+ hook: TrafficHookResult,
876
+ ) -> None:
877
+ store.append_event(
878
+ operation_id,
879
+ message=f"Recovery traffic hook {hook.action.value} reached a terminal outcome.",
880
+ evidence={
881
+ "traffic_hook": hook.as_evidence(),
882
+ "hook_summary": hook_summary(hook).model_dump(mode="json"),
883
+ },
884
+ )
885
+
886
+
887
+ def _require_recovery_hook(
888
+ hook: TrafficHookResult,
889
+ operation_log: Path,
890
+ plan: RecoveryPlan,
891
+ ) -> None:
892
+ if hook.passed:
893
+ return
894
+ raise RecoveryRequiredError(
895
+ f"recovery traffic hook {hook.action.value} ended {hook.command.outcome.value}",
896
+ production_changed=plan.production_may_have_changed,
897
+ previous_application_running=None,
898
+ log_path=operation_log,
899
+ next_safe_action="Keep maintenance enabled and inspect the recorded hook evidence.",
900
+ )
901
+
902
+
903
+ def _normalize_recovery_error(
904
+ error: BaseException,
905
+ plan: RecoveryPlan,
906
+ operation_log: Path,
907
+ loaded: LoadedConfiguration,
908
+ ) -> DployDBError | None:
909
+ if not isinstance(error, Exception):
910
+ return None
911
+ detail = (
912
+ error.payload.what_failed
913
+ if isinstance(error, DployDBError)
914
+ else loaded.secrets.redact_text(f"{type(error).__name__}: {error}")
915
+ )
916
+ return RecoveryRequiredError(
917
+ "recovery execution could not be proven: " + detail,
918
+ production_changed=plan.production_may_have_changed,
919
+ previous_application_running=None,
920
+ log_path=operation_log,
921
+ next_safe_action=(
922
+ "Preserve the recovery log and rerun dploydb recover; repeated actions are "
923
+ "re-inspected before execution."
924
+ ),
925
+ )
926
+
927
+
928
+ def _finish_recovery_operation(
929
+ store: StateStore,
930
+ operation_id: str,
931
+ error: DployDBError,
932
+ ) -> None:
933
+ current = store.read_manifest(operation_id)
934
+ if current.status is not OperationStatus.IN_PROGRESS:
935
+ return
936
+ store.transition(
937
+ operation_id,
938
+ status=OperationStatus.RECOVERY_REQUIRED,
939
+ stage="recovery_required",
940
+ message="Recovery execution did not reach a proven terminal state.",
941
+ safety=SafetyFacts(
942
+ production_changed=error.payload.production_changed,
943
+ previous_application_running=error.payload.previous_application_running,
944
+ recovery_required=True,
945
+ ),
946
+ failure=FailureRecord(
947
+ error_code=error.payload.error_code,
948
+ what_failed=error.payload.what_failed,
949
+ log_path=error.payload.log_path,
950
+ next_safe_action=error.payload.next_safe_action,
951
+ ),
952
+ )
953
+
954
+
955
+ def _no_fault(_stage: str) -> None:
956
+ return
957
+
958
+
959
+ def build_recovery_plan(
960
+ operation: OperationManifest,
961
+ events: list[OperationEvent],
962
+ release: ReleaseManifest,
963
+ live: RecoveryLiveState,
964
+ ) -> RecoveryPlan:
965
+ """Reconcile durable and live facts without performing a mutation."""
966
+ mismatch = _identity_problem(operation, events, release)
967
+ if mismatch is not None:
968
+ return _manual(operation, release, mismatch, production_changed=True)
969
+
970
+ if release.status in {
971
+ DeploymentState.ACTIVE,
972
+ DeploymentState.ROLLED_BACK,
973
+ DeploymentState.FAILED_SAFE,
974
+ } and (
975
+ operation.status is not OperationStatus.IN_PROGRESS
976
+ or release.recovery_operation_id is not None
977
+ ):
978
+ return RecoveryPlan(
979
+ disposition=RecoveryDisposition.NO_ACTION,
980
+ release_id=release.release_id,
981
+ operation_id=operation.operation_id,
982
+ durable_stage=operation.stage,
983
+ production_may_have_changed=release.production_changed,
984
+ traffic_may_have_switched=release.traffic_activated,
985
+ automatic_database_restore_allowed=not release.traffic_activated,
986
+ actions=(),
987
+ reason="The deployment already has a proven terminal state.",
988
+ next_safe_action="No recovery action is required.",
989
+ final_backup_id=release.final_backup_id,
990
+ )
991
+
992
+ if release.recovery_protocol_version != 2:
993
+ return _manual(
994
+ operation,
995
+ release,
996
+ "The interrupted release predates durable recovery intent markers.",
997
+ production_changed=True,
998
+ )
999
+
1000
+ activation = _activation_conclusion(release, events)
1001
+ production_changed = (
1002
+ release.production_changed
1003
+ or release.production_migration_started
1004
+ or operation.safety.production_changed
1005
+ )
1006
+ if activation == "succeeded":
1007
+ if live.new_application is not ApplicationRuntimeState.RUNNING:
1008
+ return _manual(
1009
+ operation,
1010
+ release,
1011
+ "New traffic was activated but the exact checked application is not running.",
1012
+ production_changed=True,
1013
+ traffic_may_have_switched=True,
1014
+ )
1015
+ return RecoveryPlan(
1016
+ disposition=RecoveryDisposition.COMPLETE_NEW,
1017
+ release_id=release.release_id,
1018
+ operation_id=operation.operation_id,
1019
+ durable_stage=operation.stage,
1020
+ production_may_have_changed=True,
1021
+ traffic_may_have_switched=True,
1022
+ automatic_database_restore_allowed=False,
1023
+ actions=(
1024
+ RecoveryAction.DISABLE_MAINTENANCE,
1025
+ RecoveryAction.VERIFY_NEW,
1026
+ RecoveryAction.MARK_ACTIVE,
1027
+ ),
1028
+ reason="New traffic activation succeeded before the operation was interrupted.",
1029
+ next_safe_action=(
1030
+ "Keep the new database and application; finish maintenance cleanup and verify "
1031
+ "the new release."
1032
+ ),
1033
+ final_backup_id=release.final_backup_id,
1034
+ )
1035
+ if activation == "uncertain":
1036
+ return _manual(
1037
+ operation,
1038
+ release,
1039
+ "New-traffic activation was attempted without proof of the live target.",
1040
+ production_changed=True,
1041
+ traffic_may_have_switched=True,
1042
+ )
1043
+
1044
+ if release.status in {
1045
+ DeploymentState.CREATED,
1046
+ DeploymentState.PREFLIGHT_PASSED,
1047
+ DeploymentState.SNAPSHOT_VERIFIED,
1048
+ DeploymentState.REHEARSAL_PASSED,
1049
+ DeploymentState.CANDIDATE_HEALTHY,
1050
+ }:
1051
+ return _manual(
1052
+ operation,
1053
+ release,
1054
+ "The operation stopped before the exact production application was durably stored.",
1055
+ production_changed=False,
1056
+ )
1057
+
1058
+ if release.previous_application is None:
1059
+ return _manual(
1060
+ operation,
1061
+ release,
1062
+ "The exact previous application identity is missing.",
1063
+ production_changed=production_changed,
1064
+ )
1065
+ if live.previous_application in {
1066
+ ApplicationRuntimeState.ABSENT,
1067
+ ApplicationRuntimeState.UNKNOWN,
1068
+ }:
1069
+ return _manual(
1070
+ operation,
1071
+ release,
1072
+ "The exact previous application cannot be inspected safely.",
1073
+ production_changed=production_changed,
1074
+ )
1075
+ if live.new_application is ApplicationRuntimeState.UNKNOWN:
1076
+ return _manual(
1077
+ operation,
1078
+ release,
1079
+ "New-release application presence cannot be proven.",
1080
+ production_changed=production_changed,
1081
+ )
1082
+
1083
+ actions: list[RecoveryAction] = []
1084
+ if live.new_application in {
1085
+ ApplicationRuntimeState.RUNNING,
1086
+ ApplicationRuntimeState.STOPPED,
1087
+ }:
1088
+ if release.new_application is None:
1089
+ return _manual(
1090
+ operation,
1091
+ release,
1092
+ "A new-release resource exists without a durable exact application handle.",
1093
+ production_changed=production_changed,
1094
+ )
1095
+ actions.append(RecoveryAction.REMOVE_NEW_APPLICATION)
1096
+
1097
+ database_matches_final = (
1098
+ live.production_database_sha256 is not None
1099
+ and live.production_database_sha256 == live.final_backup_sha256
1100
+ )
1101
+ if production_changed:
1102
+ if release.final_backup_id is None or not live.final_backup_verified:
1103
+ return _manual(
1104
+ operation,
1105
+ release,
1106
+ "Production may have changed but the final backup is not verified.",
1107
+ production_changed=True,
1108
+ )
1109
+ if not database_matches_final:
1110
+ if live.previous_application is ApplicationRuntimeState.RUNNING:
1111
+ return _manual(
1112
+ operation,
1113
+ release,
1114
+ "The previous application is running while the database needs restoration.",
1115
+ production_changed=True,
1116
+ )
1117
+ actions.append(RecoveryAction.RESTORE_FINAL_BACKUP)
1118
+
1119
+ if live.previous_application is ApplicationRuntimeState.STOPPED:
1120
+ actions.append(RecoveryAction.RESTART_PREVIOUS_APPLICATION)
1121
+ actions.extend(
1122
+ (
1123
+ RecoveryAction.ACTIVATE_PREVIOUS_TRAFFIC,
1124
+ RecoveryAction.DISABLE_MAINTENANCE,
1125
+ RecoveryAction.VERIFY_PREVIOUS,
1126
+ RecoveryAction.MARK_ROLLED_BACK,
1127
+ )
1128
+ )
1129
+ return RecoveryPlan(
1130
+ disposition=RecoveryDisposition.RECOVER_PREVIOUS,
1131
+ release_id=release.release_id,
1132
+ operation_id=operation.operation_id,
1133
+ durable_stage=operation.stage,
1134
+ production_may_have_changed=production_changed,
1135
+ traffic_may_have_switched=False,
1136
+ automatic_database_restore_allowed=True,
1137
+ actions=tuple(actions),
1138
+ reason="Durable evidence proves recovery to the previous release is safe.",
1139
+ next_safe_action="Execute the ordered recovery plan and verify every resulting state.",
1140
+ final_backup_id=release.final_backup_id,
1141
+ )
1142
+
1143
+
1144
+ def _identity_problem(
1145
+ operation: OperationManifest,
1146
+ events: list[OperationEvent],
1147
+ release: ReleaseManifest,
1148
+ ) -> str | None:
1149
+ if operation.operation_type != "deploy":
1150
+ return "The unfinished operation is not a deployment."
1151
+ if operation.operation_id != release.operation_id:
1152
+ return "The release and operation identities do not match."
1153
+ if not events or events[-1].operation_id != operation.operation_id:
1154
+ return "The operation event trail is missing or belongs to another operation."
1155
+ if events[-1].sequence != operation.last_event_sequence:
1156
+ return "The operation event trail is incomplete."
1157
+ return None
1158
+
1159
+
1160
+ def _activation_conclusion(
1161
+ release: ReleaseManifest,
1162
+ events: list[OperationEvent],
1163
+ ) -> str:
1164
+ if release.traffic_activated:
1165
+ return "succeeded"
1166
+ if not release.traffic_activation_attempted:
1167
+ return "not_attempted"
1168
+ terminal: dict[str, Any] | None = None
1169
+ for event in events:
1170
+ candidate = event.evidence.get("traffic_hook")
1171
+ if isinstance(candidate, dict) and candidate.get("action") == "activate_new":
1172
+ terminal = candidate
1173
+ if terminal is None:
1174
+ return "uncertain"
1175
+ command = terminal.get("command")
1176
+ if not isinstance(command, dict):
1177
+ return "uncertain"
1178
+ outcome = command.get("outcome")
1179
+ exit_code = command.get("exit_code")
1180
+ if terminal.get("passed") is True and outcome == "succeeded":
1181
+ return "succeeded"
1182
+ if outcome == "start_failed" or (outcome == "cancelled" and exit_code is None):
1183
+ return "not_started"
1184
+ return "uncertain"
1185
+
1186
+
1187
+ def _manual(
1188
+ operation: OperationManifest,
1189
+ release: ReleaseManifest,
1190
+ reason: str,
1191
+ *,
1192
+ production_changed: bool,
1193
+ traffic_may_have_switched: bool = False,
1194
+ ) -> RecoveryPlan:
1195
+ return RecoveryPlan(
1196
+ disposition=RecoveryDisposition.MANUAL_REQUIRED,
1197
+ release_id=release.release_id,
1198
+ operation_id=operation.operation_id,
1199
+ durable_stage=operation.stage,
1200
+ production_may_have_changed=production_changed,
1201
+ traffic_may_have_switched=traffic_may_have_switched,
1202
+ automatic_database_restore_allowed=False,
1203
+ actions=(),
1204
+ reason=reason,
1205
+ next_safe_action=(
1206
+ "Preserve all evidence, determine the live application and traffic target, and do "
1207
+ "not restore the database automatically."
1208
+ ),
1209
+ final_backup_id=release.final_backup_id,
1210
+ )