dploydb 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dploydb/__init__.py +1 -0
- dploydb/__main__.py +6 -0
- dploydb/backup.py +455 -0
- dploydb/candidate.py +868 -0
- dploydb/cli.py +1111 -0
- dploydb/config.py +784 -0
- dploydb/cutover.py +326 -0
- dploydb/deploy.py +1163 -0
- dploydb/deployment_dependencies.py +366 -0
- dploydb/deployment_evidence.py +136 -0
- dploydb/diagnostics.py +1020 -0
- dploydb/errors.py +140 -0
- dploydb/health.py +625 -0
- dploydb/locking.py +609 -0
- dploydb/manual_restore.py +825 -0
- dploydb/migration.py +575 -0
- dploydb/models.py +927 -0
- dploydb/recovery.py +1210 -0
- dploydb/redaction.py +229 -0
- dploydb/releases.py +611 -0
- dploydb/restore.py +461 -0
- dploydb/retention.py +165 -0
- dploydb/runners/__init__.py +33 -0
- dploydb/runners/base.py +389 -0
- dploydb/runners/docker_compose.py +590 -0
- dploydb/runners/docker_compose_production.py +966 -0
- dploydb/sqlite_checks.py +136 -0
- dploydb/state.py +604 -0
- dploydb/storage/__init__.py +13 -0
- dploydb/storage/base.py +52 -0
- dploydb/storage/local.py +301 -0
- dploydb/storage/s3.py +737 -0
- dploydb/subprocesses.py +641 -0
- dploydb/traffic.py +165 -0
- dploydb-0.1.0.dist-info/METADATA +583 -0
- dploydb-0.1.0.dist-info/RECORD +40 -0
- dploydb-0.1.0.dist-info/WHEEL +4 -0
- dploydb-0.1.0.dist-info/entry_points.txt +2 -0
- dploydb-0.1.0.dist-info/licenses/LICENSE +201 -0
- dploydb-0.1.0.dist-info/licenses/NOTICE +4 -0
dploydb/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
|
+
)
|