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/deploy.py
ADDED
|
@@ -0,0 +1,1163 @@
|
|
|
1
|
+
"""Internal Milestone 5 deployment coordinator and pre-traffic rollback."""
|
|
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, field
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import cast
|
|
11
|
+
|
|
12
|
+
from dploydb.candidate import CandidateStageObserver, HealthChecker
|
|
13
|
+
from dploydb.config import (
|
|
14
|
+
LoadedConfiguration,
|
|
15
|
+
configuration_fingerprint,
|
|
16
|
+
require_deploy_topology,
|
|
17
|
+
)
|
|
18
|
+
from dploydb.deployment_dependencies import (
|
|
19
|
+
DeploymentDependencies,
|
|
20
|
+
default_dependencies,
|
|
21
|
+
)
|
|
22
|
+
from dploydb.deployment_evidence import (
|
|
23
|
+
backup_evidence as _backup_evidence,
|
|
24
|
+
)
|
|
25
|
+
from dploydb.deployment_evidence import (
|
|
26
|
+
cleanup_evidence as _cleanup_evidence,
|
|
27
|
+
)
|
|
28
|
+
from dploydb.deployment_evidence import (
|
|
29
|
+
discovery_evidence as _discovery_evidence,
|
|
30
|
+
)
|
|
31
|
+
from dploydb.deployment_evidence import (
|
|
32
|
+
health_summary as _health_summary,
|
|
33
|
+
)
|
|
34
|
+
from dploydb.deployment_evidence import (
|
|
35
|
+
hook_summary as _hook_summary,
|
|
36
|
+
)
|
|
37
|
+
from dploydb.deployment_evidence import (
|
|
38
|
+
inspection_evidence as _inspection_evidence,
|
|
39
|
+
)
|
|
40
|
+
from dploydb.deployment_evidence import (
|
|
41
|
+
restart_evidence as _restart_evidence,
|
|
42
|
+
)
|
|
43
|
+
from dploydb.deployment_evidence import (
|
|
44
|
+
start_evidence as _start_evidence,
|
|
45
|
+
)
|
|
46
|
+
from dploydb.deployment_evidence import (
|
|
47
|
+
stop_evidence as _stop_evidence,
|
|
48
|
+
)
|
|
49
|
+
from dploydb.errors import (
|
|
50
|
+
DployDBError,
|
|
51
|
+
ExternalCommandError,
|
|
52
|
+
OperationFailedError,
|
|
53
|
+
RecoveryRequiredError,
|
|
54
|
+
)
|
|
55
|
+
from dploydb.health import ApplicationHealthChecker, ReadinessCheckError, SmokeCheckError
|
|
56
|
+
from dploydb.locking import DeploymentLock
|
|
57
|
+
from dploydb.migration import require_clean_operation_state
|
|
58
|
+
from dploydb.models import (
|
|
59
|
+
BackupArtifact,
|
|
60
|
+
DeploymentState,
|
|
61
|
+
FailureRecord,
|
|
62
|
+
MigrationCommandEvidence,
|
|
63
|
+
OperationManifest,
|
|
64
|
+
OperationStatus,
|
|
65
|
+
ProductionApplicationHandle,
|
|
66
|
+
ReleaseHealthEvidence,
|
|
67
|
+
ReleaseHookEvidence,
|
|
68
|
+
ReleaseManifest,
|
|
69
|
+
SafetyFacts,
|
|
70
|
+
)
|
|
71
|
+
from dploydb.redaction import JsonValue
|
|
72
|
+
from dploydb.releases import ReleaseStore
|
|
73
|
+
from dploydb.runners.base import (
|
|
74
|
+
ApplicationRunner,
|
|
75
|
+
ProductionCleanupError,
|
|
76
|
+
ProductionInspectionError,
|
|
77
|
+
ProductionLogs,
|
|
78
|
+
ProductionRunnerError,
|
|
79
|
+
ProductionStartError,
|
|
80
|
+
ProductionStop,
|
|
81
|
+
validate_release_identifier,
|
|
82
|
+
)
|
|
83
|
+
from dploydb.sqlite_checks import verify_sqlite_database
|
|
84
|
+
from dploydb.state import StateStore
|
|
85
|
+
from dploydb.subprocesses import CommandOutcome, SubprocessRunner
|
|
86
|
+
from dploydb.traffic import TrafficHookResult
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True, slots=True)
|
|
90
|
+
class DeploymentResult:
|
|
91
|
+
"""Terminal active or fully proven rolled-back deployment evidence."""
|
|
92
|
+
|
|
93
|
+
release: ReleaseManifest
|
|
94
|
+
operation: OperationManifest
|
|
95
|
+
|
|
96
|
+
@property
|
|
97
|
+
def active(self) -> bool:
|
|
98
|
+
return self.release.status is DeploymentState.ACTIVE
|
|
99
|
+
|
|
100
|
+
@property
|
|
101
|
+
def rolled_back(self) -> bool:
|
|
102
|
+
return self.release.status is DeploymentState.ROLLED_BACK
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(slots=True)
|
|
106
|
+
class _Context:
|
|
107
|
+
loaded: LoadedConfiguration
|
|
108
|
+
version: str
|
|
109
|
+
operation_id: str
|
|
110
|
+
release_id: str
|
|
111
|
+
store: StateStore
|
|
112
|
+
releases: ReleaseStore
|
|
113
|
+
dependencies: DeploymentDependencies
|
|
114
|
+
operation_log: Path
|
|
115
|
+
cancellation_event: threading.Event | None
|
|
116
|
+
fault_injector: Callable[[str], None]
|
|
117
|
+
active_release: ReleaseManifest | None
|
|
118
|
+
previous: ProductionApplicationHandle | None = None
|
|
119
|
+
stopped: ProductionStop | None = None
|
|
120
|
+
final_backup: BackupArtifact | None = None
|
|
121
|
+
new_application: ProductionApplicationHandle | None = None
|
|
122
|
+
maintenance_enabled: bool = False
|
|
123
|
+
production_may_have_changed: bool = False
|
|
124
|
+
traffic_activation_attempted: bool = False
|
|
125
|
+
traffic_activated: bool = False
|
|
126
|
+
hook_evidence: list[ReleaseHookEvidence] = field(default_factory=list)
|
|
127
|
+
health_evidence: list[ReleaseHealthEvidence] = field(default_factory=list)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def deploy_configured_release(
|
|
131
|
+
loaded: LoadedConfiguration,
|
|
132
|
+
*,
|
|
133
|
+
version: str,
|
|
134
|
+
config_path: Path,
|
|
135
|
+
command_environment: Mapping[str, str] | None = None,
|
|
136
|
+
candidate_command_runner: SubprocessRunner | None = None,
|
|
137
|
+
candidate_application_runner: ApplicationRunner | None = None,
|
|
138
|
+
candidate_health_checker: HealthChecker | None = None,
|
|
139
|
+
dependencies: DeploymentDependencies | None = None,
|
|
140
|
+
cancellation_event: threading.Event | None = None,
|
|
141
|
+
fault_injector: Callable[[str], None] | None = None,
|
|
142
|
+
) -> DeploymentResult:
|
|
143
|
+
"""Deploy one release or return a fully proven pre-traffic rollback."""
|
|
144
|
+
release_version = validate_release_identifier(version)
|
|
145
|
+
topology = require_deploy_topology(loaded.config)
|
|
146
|
+
selected_environment = dict(os.environ if command_environment is None else command_environment)
|
|
147
|
+
working_directory = config_path.resolve().parent
|
|
148
|
+
store = StateStore(loaded.config.state_directory, secrets=loaded.secrets)
|
|
149
|
+
releases = ReleaseStore(loaded.config.state_directory, secrets=loaded.secrets)
|
|
150
|
+
lock = DeploymentLock(loaded.config.state_directory, secrets=loaded.secrets)
|
|
151
|
+
owned_health: ApplicationHealthChecker | None = None
|
|
152
|
+
|
|
153
|
+
with lock:
|
|
154
|
+
require_clean_operation_state(lock, store)
|
|
155
|
+
fingerprint = configuration_fingerprint(loaded.config, secrets=loaded.secrets)
|
|
156
|
+
operation = store.create_operation(
|
|
157
|
+
operation_type="deploy",
|
|
158
|
+
project=loaded.config.project,
|
|
159
|
+
configuration_fingerprint=fingerprint,
|
|
160
|
+
evidence={
|
|
161
|
+
"database_path": str(loaded.config.database.path),
|
|
162
|
+
"requested_version": release_version,
|
|
163
|
+
},
|
|
164
|
+
)
|
|
165
|
+
lock.record_owner(operation_id=operation.operation_id, operation_type="deploy")
|
|
166
|
+
operation_log = store.operation_paths(operation.operation_id).events
|
|
167
|
+
release: ReleaseManifest | None = None
|
|
168
|
+
try:
|
|
169
|
+
selected_dependencies = dependencies
|
|
170
|
+
if selected_dependencies is None:
|
|
171
|
+
selected_dependencies, owned_health = default_dependencies(
|
|
172
|
+
loaded,
|
|
173
|
+
topology=topology,
|
|
174
|
+
config_path=config_path,
|
|
175
|
+
working_directory=working_directory,
|
|
176
|
+
command_environment=selected_environment,
|
|
177
|
+
candidate_command_runner=candidate_command_runner,
|
|
178
|
+
candidate_application_runner=candidate_application_runner,
|
|
179
|
+
candidate_health_checker=candidate_health_checker,
|
|
180
|
+
)
|
|
181
|
+
active_release = releases.active_release()
|
|
182
|
+
release = releases.create_release(
|
|
183
|
+
operation_id=operation.operation_id,
|
|
184
|
+
project=loaded.config.project,
|
|
185
|
+
requested_version=release_version,
|
|
186
|
+
configuration_fingerprint=fingerprint,
|
|
187
|
+
operation_log_path=operation_log,
|
|
188
|
+
previous_application=(
|
|
189
|
+
None if active_release is None else active_release.new_application
|
|
190
|
+
),
|
|
191
|
+
)
|
|
192
|
+
context = _Context(
|
|
193
|
+
loaded=loaded,
|
|
194
|
+
version=release_version,
|
|
195
|
+
operation_id=operation.operation_id,
|
|
196
|
+
release_id=release.release_id,
|
|
197
|
+
store=store,
|
|
198
|
+
releases=releases,
|
|
199
|
+
dependencies=selected_dependencies,
|
|
200
|
+
operation_log=operation_log,
|
|
201
|
+
cancellation_event=cancellation_event,
|
|
202
|
+
fault_injector=fault_injector or _no_fault,
|
|
203
|
+
active_release=active_release,
|
|
204
|
+
previous=(None if active_release is None else active_release.new_application),
|
|
205
|
+
)
|
|
206
|
+
return _run_deployment(context, config_path=config_path, lock=lock)
|
|
207
|
+
except DployDBError as error:
|
|
208
|
+
if release is None:
|
|
209
|
+
_finish_operation_without_release(store, operation.operation_id, error)
|
|
210
|
+
raise
|
|
211
|
+
except Exception as raw_error:
|
|
212
|
+
unexpected_error = RecoveryRequiredError(
|
|
213
|
+
"deployment coordinator failed unexpectedly: "
|
|
214
|
+
+ loaded.secrets.redact_text(f"{type(raw_error).__name__}: {raw_error}"),
|
|
215
|
+
production_changed=False,
|
|
216
|
+
previous_application_running=None,
|
|
217
|
+
log_path=operation_log,
|
|
218
|
+
next_safe_action=(
|
|
219
|
+
"Preserve the operation and release evidence; inspect production before "
|
|
220
|
+
"taking any recovery action."
|
|
221
|
+
),
|
|
222
|
+
)
|
|
223
|
+
if release is None:
|
|
224
|
+
_finish_operation_without_release(store, operation.operation_id, unexpected_error)
|
|
225
|
+
raise unexpected_error from None
|
|
226
|
+
finally:
|
|
227
|
+
if owned_health is not None:
|
|
228
|
+
owned_health.close()
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _run_deployment(
|
|
232
|
+
context: _Context,
|
|
233
|
+
*,
|
|
234
|
+
config_path: Path,
|
|
235
|
+
lock: DeploymentLock,
|
|
236
|
+
) -> DeploymentResult:
|
|
237
|
+
try:
|
|
238
|
+
observer = _release_stage_observer(context)
|
|
239
|
+
candidate = context.dependencies.pre_cutover.run(
|
|
240
|
+
context.loaded,
|
|
241
|
+
version=context.version,
|
|
242
|
+
config_path=config_path,
|
|
243
|
+
operation_id=context.operation_id,
|
|
244
|
+
store=context.store,
|
|
245
|
+
lock=lock,
|
|
246
|
+
cancellation_event=context.cancellation_event,
|
|
247
|
+
stage_observer=observer,
|
|
248
|
+
)
|
|
249
|
+
context.store.append_event(
|
|
250
|
+
context.operation_id,
|
|
251
|
+
message="Caller-owned candidate stage returned complete passing evidence.",
|
|
252
|
+
evidence={"candidate_stage": candidate.as_evidence()},
|
|
253
|
+
)
|
|
254
|
+
_resolve_previous_application(context)
|
|
255
|
+
_enable_maintenance(context)
|
|
256
|
+
context.fault_injector("after_maintenance_enabled")
|
|
257
|
+
assert context.previous is not None
|
|
258
|
+
stopped = context.dependencies.production.stop_current(
|
|
259
|
+
context.previous,
|
|
260
|
+
cancellation_event=context.cancellation_event,
|
|
261
|
+
)
|
|
262
|
+
context.stopped = stopped
|
|
263
|
+
_persist_stage(
|
|
264
|
+
context,
|
|
265
|
+
DeploymentState.CURRENT_APP_STOPPED,
|
|
266
|
+
message="The exact previous application was stopped and inspected.",
|
|
267
|
+
evidence={"production_stop": _stop_evidence(stopped)},
|
|
268
|
+
previous_application=context.previous,
|
|
269
|
+
safety=SafetyFacts(
|
|
270
|
+
production_changed=False,
|
|
271
|
+
previous_application_running=False,
|
|
272
|
+
recovery_required=False,
|
|
273
|
+
),
|
|
274
|
+
)
|
|
275
|
+
context.fault_injector("after_current_app_stopped")
|
|
276
|
+
final_backup = context.dependencies.database.create_final(
|
|
277
|
+
operation_id=context.operation_id,
|
|
278
|
+
stopped=stopped,
|
|
279
|
+
)
|
|
280
|
+
context.final_backup = final_backup
|
|
281
|
+
_persist_stage(
|
|
282
|
+
context,
|
|
283
|
+
DeploymentState.FINAL_SNAPSHOT_VERIFIED,
|
|
284
|
+
message="The stopped-writer final backup was created and reverified.",
|
|
285
|
+
evidence={"final_backup": _backup_evidence(final_backup)},
|
|
286
|
+
final_backup_id=final_backup.metadata.backup_id,
|
|
287
|
+
final_backup_sha256=final_backup.metadata.sha256,
|
|
288
|
+
safety=SafetyFacts(
|
|
289
|
+
production_changed=False,
|
|
290
|
+
previous_application_running=False,
|
|
291
|
+
recovery_required=False,
|
|
292
|
+
),
|
|
293
|
+
)
|
|
294
|
+
_replicate_required_final_backup(context, final_backup)
|
|
295
|
+
|
|
296
|
+
def record_migration(evidence: MigrationCommandEvidence) -> None:
|
|
297
|
+
context.store.append_event(
|
|
298
|
+
context.operation_id,
|
|
299
|
+
message="Production migration command reached a terminal outcome.",
|
|
300
|
+
evidence={"production_migration_command": evidence.model_dump(mode="json")},
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
context.production_may_have_changed = True
|
|
304
|
+
context.releases.record_recovery_intent(
|
|
305
|
+
context.release_id,
|
|
306
|
+
production_migration_started=True,
|
|
307
|
+
)
|
|
308
|
+
context.store.transition(
|
|
309
|
+
context.operation_id,
|
|
310
|
+
status=OperationStatus.IN_PROGRESS,
|
|
311
|
+
stage="production_migration_started",
|
|
312
|
+
message="Production migration is about to start; production may change.",
|
|
313
|
+
evidence={"final_backup_id": final_backup.metadata.backup_id},
|
|
314
|
+
safety=SafetyFacts(
|
|
315
|
+
production_changed=True,
|
|
316
|
+
previous_application_running=False,
|
|
317
|
+
recovery_required=False,
|
|
318
|
+
),
|
|
319
|
+
)
|
|
320
|
+
migrated = context.dependencies.database.migrate(
|
|
321
|
+
operation_id=context.operation_id,
|
|
322
|
+
stopped=stopped,
|
|
323
|
+
final_backup=final_backup,
|
|
324
|
+
traffic_activated=context.traffic_activated,
|
|
325
|
+
evidence_sink=record_migration,
|
|
326
|
+
cancellation_event=context.cancellation_event,
|
|
327
|
+
log_path=context.operation_log,
|
|
328
|
+
)
|
|
329
|
+
_persist_stage(
|
|
330
|
+
context,
|
|
331
|
+
DeploymentState.PRODUCTION_MIGRATED,
|
|
332
|
+
message="Production migration and SQLite checks passed.",
|
|
333
|
+
evidence={"production_migration": migrated.model_dump(mode="json")},
|
|
334
|
+
production_changed=True,
|
|
335
|
+
safety=SafetyFacts(
|
|
336
|
+
production_changed=True,
|
|
337
|
+
previous_application_running=False,
|
|
338
|
+
recovery_required=False,
|
|
339
|
+
),
|
|
340
|
+
)
|
|
341
|
+
context.fault_injector("after_production_migrated")
|
|
342
|
+
started = context.dependencies.production.start_new(
|
|
343
|
+
operation_id=context.operation_id,
|
|
344
|
+
release_id=context.release_id,
|
|
345
|
+
version=context.version,
|
|
346
|
+
cancellation_event=context.cancellation_event,
|
|
347
|
+
)
|
|
348
|
+
context.new_application = started.handle
|
|
349
|
+
context.store.append_event(
|
|
350
|
+
context.operation_id,
|
|
351
|
+
message="The new production application started with validated identity.",
|
|
352
|
+
evidence={"production_start": _start_evidence(started)},
|
|
353
|
+
)
|
|
354
|
+
logs = context.dependencies.production.collect_logs(
|
|
355
|
+
started.handle,
|
|
356
|
+
cancellation_event=context.cancellation_event,
|
|
357
|
+
)
|
|
358
|
+
_require_complete_logs(logs, context.operation_log)
|
|
359
|
+
database_check = verify_sqlite_database(context.loaded.config.database.path)
|
|
360
|
+
health = context.dependencies.health.check_application(
|
|
361
|
+
version=context.version,
|
|
362
|
+
database_path=context.loaded.config.database.path,
|
|
363
|
+
cancellation_event=context.cancellation_event,
|
|
364
|
+
)
|
|
365
|
+
context.health_evidence.append(_health_summary(health, role="new", version=context.version))
|
|
366
|
+
_persist_stage(
|
|
367
|
+
context,
|
|
368
|
+
DeploymentState.NEW_APP_HEALTHY,
|
|
369
|
+
message="Final production database, logs, HTTP, and smoke checks passed.",
|
|
370
|
+
evidence={
|
|
371
|
+
"production_logs": logs.command.as_evidence(),
|
|
372
|
+
"database": database_check.model_dump(mode="json"),
|
|
373
|
+
"application_health": health.as_evidence(),
|
|
374
|
+
},
|
|
375
|
+
new_application=started.handle,
|
|
376
|
+
production_health_passed=True,
|
|
377
|
+
production_changed=True,
|
|
378
|
+
safety=SafetyFacts(
|
|
379
|
+
production_changed=True,
|
|
380
|
+
previous_application_running=False,
|
|
381
|
+
recovery_required=False,
|
|
382
|
+
),
|
|
383
|
+
)
|
|
384
|
+
_activate_new_traffic(context)
|
|
385
|
+
_disable_maintenance_after_activation(context)
|
|
386
|
+
return _complete_active(context)
|
|
387
|
+
except BaseException as raw_error:
|
|
388
|
+
error = _normalize_deployment_error(raw_error, context)
|
|
389
|
+
if error is None:
|
|
390
|
+
raise
|
|
391
|
+
if error.payload.recovery_required or context.traffic_activated:
|
|
392
|
+
_finish_recovery_required(context, error)
|
|
393
|
+
raise error from None
|
|
394
|
+
if context.traffic_activation_attempted:
|
|
395
|
+
uncertain = RecoveryRequiredError(
|
|
396
|
+
"new-traffic activation was attempted without proof of a safe routing state: "
|
|
397
|
+
+ error.payload.what_failed,
|
|
398
|
+
production_changed=True,
|
|
399
|
+
previous_application_running=False,
|
|
400
|
+
log_path=context.operation_log,
|
|
401
|
+
next_safe_action=(
|
|
402
|
+
"Do not restore the database. Keep the checked new application available "
|
|
403
|
+
"and determine the live traffic target before recovery."
|
|
404
|
+
),
|
|
405
|
+
)
|
|
406
|
+
_finish_recovery_required(context, uncertain)
|
|
407
|
+
raise uncertain from None
|
|
408
|
+
if context.maintenance_enabled:
|
|
409
|
+
return _rollback(context, error)
|
|
410
|
+
_finish_failed_before_rollback(context, error)
|
|
411
|
+
raise error from None
|
|
412
|
+
|
|
413
|
+
|
|
414
|
+
def _release_stage_observer(context: _Context) -> CandidateStageObserver:
|
|
415
|
+
def observe(stage: DeploymentState, evidence: Mapping[str, JsonValue]) -> None:
|
|
416
|
+
changes: dict[str, object] = {}
|
|
417
|
+
if stage is DeploymentState.SNAPSHOT_VERIFIED:
|
|
418
|
+
backup_id = evidence.get("backup_id")
|
|
419
|
+
sha256 = evidence.get("sha256")
|
|
420
|
+
if not isinstance(backup_id, str) or not isinstance(sha256, str):
|
|
421
|
+
raise RecoveryRequiredError(
|
|
422
|
+
"candidate snapshot evidence is incomplete for the release manifest",
|
|
423
|
+
production_changed=False,
|
|
424
|
+
previous_application_running=None,
|
|
425
|
+
log_path=context.operation_log,
|
|
426
|
+
next_safe_action="Preserve the operation evidence and inspect the backup.",
|
|
427
|
+
)
|
|
428
|
+
changes = {
|
|
429
|
+
"rehearsal_backup_id": backup_id,
|
|
430
|
+
"rehearsal_backup_sha256": sha256,
|
|
431
|
+
}
|
|
432
|
+
context.releases.transition(
|
|
433
|
+
context.release_id,
|
|
434
|
+
status=stage,
|
|
435
|
+
traffic_hooks=tuple(context.hook_evidence),
|
|
436
|
+
health_checks=tuple(context.health_evidence),
|
|
437
|
+
**changes,
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
return observe
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _resolve_previous_application(context: _Context) -> None:
|
|
444
|
+
if context.previous is not None:
|
|
445
|
+
inspection = context.dependencies.production.inspect(
|
|
446
|
+
context.previous,
|
|
447
|
+
expected_running=True,
|
|
448
|
+
cancellation_event=context.cancellation_event,
|
|
449
|
+
)
|
|
450
|
+
evidence: dict[str, JsonValue] = {
|
|
451
|
+
"source": "active_release",
|
|
452
|
+
"inspection": _inspection_evidence(inspection),
|
|
453
|
+
}
|
|
454
|
+
else:
|
|
455
|
+
discovery = context.dependencies.production.discover_current(
|
|
456
|
+
cancellation_event=context.cancellation_event
|
|
457
|
+
)
|
|
458
|
+
context.previous = discovery.inspection.handle
|
|
459
|
+
evidence = {
|
|
460
|
+
"source": "configured_bootstrap",
|
|
461
|
+
"discovery": _discovery_evidence(discovery),
|
|
462
|
+
}
|
|
463
|
+
context.store.append_event(
|
|
464
|
+
context.operation_id,
|
|
465
|
+
message="The exact currently running production application was identified.",
|
|
466
|
+
evidence={"previous_application": evidence},
|
|
467
|
+
)
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _enable_maintenance(context: _Context) -> None:
|
|
471
|
+
result = context.dependencies.traffic.enable_maintenance(
|
|
472
|
+
cancellation_event=context.cancellation_event
|
|
473
|
+
)
|
|
474
|
+
_record_hook(context, result)
|
|
475
|
+
if not result.passed:
|
|
476
|
+
cleanup = context.dependencies.traffic.disable_maintenance(
|
|
477
|
+
cancellation_event=context.cancellation_event
|
|
478
|
+
)
|
|
479
|
+
_record_hook(context, cleanup)
|
|
480
|
+
error = _hook_error(result, context, production_changed=False)
|
|
481
|
+
if not cleanup.passed:
|
|
482
|
+
raise RecoveryRequiredError(
|
|
483
|
+
error.payload.what_failed
|
|
484
|
+
+ "; maintenance cleanup also failed: "
|
|
485
|
+
+ _hook_description(cleanup),
|
|
486
|
+
production_changed=False,
|
|
487
|
+
previous_application_running=True,
|
|
488
|
+
log_path=context.operation_log,
|
|
489
|
+
next_safe_action=(
|
|
490
|
+
"The previous application was not stopped. Determine and disable the "
|
|
491
|
+
"maintenance state manually before retrying."
|
|
492
|
+
),
|
|
493
|
+
)
|
|
494
|
+
_verify_previous(context)
|
|
495
|
+
raise error
|
|
496
|
+
context.maintenance_enabled = True
|
|
497
|
+
assert context.previous is not None
|
|
498
|
+
_persist_stage(
|
|
499
|
+
context,
|
|
500
|
+
DeploymentState.MAINTENANCE_ENABLED,
|
|
501
|
+
message="Maintenance mode was enabled with complete hook evidence.",
|
|
502
|
+
evidence={"traffic_hook": result.as_evidence()},
|
|
503
|
+
previous_application=context.previous,
|
|
504
|
+
safety=SafetyFacts(
|
|
505
|
+
production_changed=False,
|
|
506
|
+
previous_application_running=True,
|
|
507
|
+
recovery_required=False,
|
|
508
|
+
),
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _activate_new_traffic(context: _Context) -> None:
|
|
513
|
+
context.traffic_activation_attempted = True
|
|
514
|
+
context.releases.record_recovery_intent(
|
|
515
|
+
context.release_id,
|
|
516
|
+
traffic_activation_attempted=True,
|
|
517
|
+
)
|
|
518
|
+
context.store.transition(
|
|
519
|
+
context.operation_id,
|
|
520
|
+
status=OperationStatus.IN_PROGRESS,
|
|
521
|
+
stage="traffic_activation_started",
|
|
522
|
+
message="New-traffic activation is about to run; routing may become uncertain.",
|
|
523
|
+
evidence={"release_id": context.release_id},
|
|
524
|
+
safety=SafetyFacts(
|
|
525
|
+
production_changed=True,
|
|
526
|
+
previous_application_running=False,
|
|
527
|
+
recovery_required=False,
|
|
528
|
+
),
|
|
529
|
+
)
|
|
530
|
+
result = context.dependencies.traffic.activate_new(
|
|
531
|
+
cancellation_event=context.cancellation_event
|
|
532
|
+
)
|
|
533
|
+
_record_hook(context, result)
|
|
534
|
+
if not result.passed:
|
|
535
|
+
context.traffic_activation_attempted = _hook_may_have_run(result)
|
|
536
|
+
raise _hook_error(result, context, production_changed=True)
|
|
537
|
+
context.traffic_activated = True
|
|
538
|
+
_persist_stage(
|
|
539
|
+
context,
|
|
540
|
+
DeploymentState.TRAFFIC_ACTIVATED,
|
|
541
|
+
message="New-release traffic activation succeeded and was stored immediately.",
|
|
542
|
+
evidence={"traffic_hook": result.as_evidence()},
|
|
543
|
+
production_changed=True,
|
|
544
|
+
traffic_activated=True,
|
|
545
|
+
safety=SafetyFacts(
|
|
546
|
+
production_changed=True,
|
|
547
|
+
previous_application_running=False,
|
|
548
|
+
recovery_required=False,
|
|
549
|
+
),
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def _replicate_required_final_backup(
|
|
554
|
+
context: _Context,
|
|
555
|
+
final_backup: BackupArtifact,
|
|
556
|
+
) -> None:
|
|
557
|
+
remote_config = context.loaded.config.backup.remote
|
|
558
|
+
if remote_config is None or not remote_config.required:
|
|
559
|
+
return
|
|
560
|
+
replicator = context.dependencies.remote_backup
|
|
561
|
+
if replicator is None:
|
|
562
|
+
raise OperationFailedError(
|
|
563
|
+
"required remote final-backup storage is unavailable",
|
|
564
|
+
production_changed=False,
|
|
565
|
+
previous_application_running=False,
|
|
566
|
+
log_path=context.operation_log,
|
|
567
|
+
next_safe_action=(
|
|
568
|
+
"Keep production unchanged, restart the previous application, and correct "
|
|
569
|
+
"remote backup configuration before retrying deployment."
|
|
570
|
+
),
|
|
571
|
+
)
|
|
572
|
+
remote = replicator.replicate(
|
|
573
|
+
final_backup,
|
|
574
|
+
release_id=context.release_id,
|
|
575
|
+
)
|
|
576
|
+
if (
|
|
577
|
+
remote.metadata.backup != final_backup.metadata
|
|
578
|
+
or remote.metadata.release_id != context.release_id
|
|
579
|
+
):
|
|
580
|
+
raise OperationFailedError(
|
|
581
|
+
"required remote final-backup evidence contradicts the local verified backup",
|
|
582
|
+
production_changed=False,
|
|
583
|
+
previous_application_running=False,
|
|
584
|
+
log_path=context.operation_log,
|
|
585
|
+
next_safe_action=(
|
|
586
|
+
"Preserve both backup records, restart the previous application, and inspect "
|
|
587
|
+
"remote storage before retrying deployment."
|
|
588
|
+
),
|
|
589
|
+
)
|
|
590
|
+
context.store.append_event(
|
|
591
|
+
context.operation_id,
|
|
592
|
+
message="The required final backup was committed and verified off-server.",
|
|
593
|
+
evidence={"remote_final_backup": remote.model_dump(mode="json")},
|
|
594
|
+
)
|
|
595
|
+
context.fault_injector("after_final_remote_verified")
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
def _disable_maintenance_after_activation(context: _Context) -> None:
|
|
599
|
+
result = context.dependencies.traffic.disable_maintenance(
|
|
600
|
+
cancellation_event=context.cancellation_event
|
|
601
|
+
)
|
|
602
|
+
_record_hook(context, result)
|
|
603
|
+
if not result.passed:
|
|
604
|
+
raise RecoveryRequiredError(
|
|
605
|
+
"new traffic is active but maintenance disable failed: " + _hook_description(result),
|
|
606
|
+
production_changed=True,
|
|
607
|
+
previous_application_running=False,
|
|
608
|
+
log_path=context.operation_log,
|
|
609
|
+
next_safe_action=(
|
|
610
|
+
"Do not restore the database. Keep the checked new release running and "
|
|
611
|
+
"disable maintenance manually."
|
|
612
|
+
),
|
|
613
|
+
)
|
|
614
|
+
context.maintenance_enabled = False
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def _complete_active(context: _Context) -> DeploymentResult:
|
|
618
|
+
release = context.releases.transition(
|
|
619
|
+
context.release_id,
|
|
620
|
+
status=DeploymentState.ACTIVE,
|
|
621
|
+
production_changed=True,
|
|
622
|
+
traffic_activated=True,
|
|
623
|
+
traffic_hooks=tuple(context.hook_evidence),
|
|
624
|
+
health_checks=tuple(context.health_evidence),
|
|
625
|
+
)
|
|
626
|
+
pointers = context.releases.activate_release(context.release_id)
|
|
627
|
+
retention_evidence: dict[str, JsonValue] = {"status": "not_configured"}
|
|
628
|
+
if context.dependencies.retention is not None:
|
|
629
|
+
try:
|
|
630
|
+
retention = context.dependencies.retention.apply(context.releases.read_history())
|
|
631
|
+
retention_evidence = {
|
|
632
|
+
"status": "completed",
|
|
633
|
+
"result": cast(JsonValue, retention.as_evidence()),
|
|
634
|
+
}
|
|
635
|
+
context.store.append_event(
|
|
636
|
+
context.operation_id,
|
|
637
|
+
message=(
|
|
638
|
+
"Backup retention completed after the active release and pointers were durable."
|
|
639
|
+
),
|
|
640
|
+
evidence={"retention": retention_evidence},
|
|
641
|
+
)
|
|
642
|
+
except DployDBError as error:
|
|
643
|
+
retention_evidence = {
|
|
644
|
+
"status": "failed_safe",
|
|
645
|
+
"failure": cast(JsonValue, error.payload.as_dict()),
|
|
646
|
+
}
|
|
647
|
+
context.store.append_event(
|
|
648
|
+
context.operation_id,
|
|
649
|
+
message=(
|
|
650
|
+
"Backup retention did not complete; the active deployment is preserved "
|
|
651
|
+
"and no rollback is permitted after traffic activation."
|
|
652
|
+
),
|
|
653
|
+
evidence={"retention": retention_evidence},
|
|
654
|
+
)
|
|
655
|
+
except Exception as error:
|
|
656
|
+
detail = context.loaded.secrets.redact_text(f"{type(error).__name__}: {error}")
|
|
657
|
+
retention_evidence = {
|
|
658
|
+
"status": "failed_safe",
|
|
659
|
+
"failure": {"what_failed": detail},
|
|
660
|
+
}
|
|
661
|
+
context.store.append_event(
|
|
662
|
+
context.operation_id,
|
|
663
|
+
message=(
|
|
664
|
+
"Backup retention failed unexpectedly; the active deployment is "
|
|
665
|
+
"preserved and no rollback is permitted after traffic activation."
|
|
666
|
+
),
|
|
667
|
+
evidence={"retention": retention_evidence},
|
|
668
|
+
)
|
|
669
|
+
operation = context.store.transition(
|
|
670
|
+
context.operation_id,
|
|
671
|
+
status=OperationStatus.SUCCEEDED,
|
|
672
|
+
stage="active",
|
|
673
|
+
message="The new release is active and maintenance is disabled.",
|
|
674
|
+
evidence={
|
|
675
|
+
"release_id": context.release_id,
|
|
676
|
+
"active_release_id": pointers.active_release_id,
|
|
677
|
+
"previous_release_id": pointers.previous_release_id,
|
|
678
|
+
"retention": retention_evidence,
|
|
679
|
+
},
|
|
680
|
+
safety=SafetyFacts(
|
|
681
|
+
production_changed=True,
|
|
682
|
+
previous_application_running=False,
|
|
683
|
+
recovery_required=False,
|
|
684
|
+
),
|
|
685
|
+
)
|
|
686
|
+
return DeploymentResult(release=release, operation=operation)
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def _rollback(context: _Context, original: DployDBError) -> DeploymentResult:
|
|
690
|
+
try:
|
|
691
|
+
_persist_stage(
|
|
692
|
+
context,
|
|
693
|
+
DeploymentState.ROLLBACK_STARTED,
|
|
694
|
+
message="Pre-traffic rollback started after deployment failure.",
|
|
695
|
+
evidence={"original_failure": original.payload.as_dict()},
|
|
696
|
+
production_changed=context.production_may_have_changed,
|
|
697
|
+
safety=SafetyFacts(
|
|
698
|
+
production_changed=context.production_may_have_changed,
|
|
699
|
+
previous_application_running=False,
|
|
700
|
+
recovery_required=False,
|
|
701
|
+
),
|
|
702
|
+
)
|
|
703
|
+
if context.new_application is not None:
|
|
704
|
+
cleanup = context.dependencies.production.remove_new(context.new_application)
|
|
705
|
+
context.store.append_event(
|
|
706
|
+
context.operation_id,
|
|
707
|
+
message="Failed new-release application cleanup was proven.",
|
|
708
|
+
evidence={"production_cleanup": _cleanup_evidence(cleanup)},
|
|
709
|
+
)
|
|
710
|
+
if context.production_may_have_changed:
|
|
711
|
+
if context.stopped is None or context.final_backup is None:
|
|
712
|
+
raise RecoveryRequiredError(
|
|
713
|
+
"database rollback prerequisites are incomplete",
|
|
714
|
+
production_changed=True,
|
|
715
|
+
previous_application_running=False,
|
|
716
|
+
log_path=context.operation_log,
|
|
717
|
+
next_safe_action=(
|
|
718
|
+
"Keep every application and traffic path blocked; inspect the final "
|
|
719
|
+
"backup and stopped-container evidence manually."
|
|
720
|
+
),
|
|
721
|
+
)
|
|
722
|
+
restored = context.dependencies.database.restore(
|
|
723
|
+
operation_id=context.operation_id,
|
|
724
|
+
stopped=context.stopped,
|
|
725
|
+
final_backup=context.final_backup,
|
|
726
|
+
traffic_activated=context.traffic_activated,
|
|
727
|
+
)
|
|
728
|
+
context.store.append_event(
|
|
729
|
+
context.operation_id,
|
|
730
|
+
message="The verified final backup was restored before traffic activation.",
|
|
731
|
+
evidence={"database_restore": restored.model_dump(mode="json")},
|
|
732
|
+
)
|
|
733
|
+
if context.previous is None:
|
|
734
|
+
raise RecoveryRequiredError(
|
|
735
|
+
"the exact previous application identity is unavailable for rollback",
|
|
736
|
+
production_changed=context.production_may_have_changed,
|
|
737
|
+
previous_application_running=None,
|
|
738
|
+
log_path=context.operation_log,
|
|
739
|
+
next_safe_action="Keep maintenance enabled and inspect production manually.",
|
|
740
|
+
)
|
|
741
|
+
restarted = context.dependencies.production.restart_previous(
|
|
742
|
+
context.previous,
|
|
743
|
+
cancellation_event=context.cancellation_event,
|
|
744
|
+
)
|
|
745
|
+
context.store.append_event(
|
|
746
|
+
context.operation_id,
|
|
747
|
+
message="The exact previous application was restarted and inspected.",
|
|
748
|
+
evidence={"previous_restart": _restart_evidence(restarted)},
|
|
749
|
+
)
|
|
750
|
+
old_target = context.dependencies.traffic.activate_old(
|
|
751
|
+
cancellation_event=context.cancellation_event
|
|
752
|
+
)
|
|
753
|
+
_record_hook(context, old_target)
|
|
754
|
+
if not old_target.passed:
|
|
755
|
+
raise _hook_error(
|
|
756
|
+
old_target,
|
|
757
|
+
context,
|
|
758
|
+
production_changed=context.production_may_have_changed,
|
|
759
|
+
recovery=True,
|
|
760
|
+
)
|
|
761
|
+
maintenance_off = context.dependencies.traffic.disable_maintenance(
|
|
762
|
+
cancellation_event=context.cancellation_event
|
|
763
|
+
)
|
|
764
|
+
_record_hook(context, maintenance_off)
|
|
765
|
+
if not maintenance_off.passed:
|
|
766
|
+
raise _hook_error(
|
|
767
|
+
maintenance_off,
|
|
768
|
+
context,
|
|
769
|
+
production_changed=context.production_may_have_changed,
|
|
770
|
+
recovery=True,
|
|
771
|
+
)
|
|
772
|
+
context.maintenance_enabled = False
|
|
773
|
+
verification = _verify_previous(context)
|
|
774
|
+
failure = _failure_record(original)
|
|
775
|
+
release = context.releases.transition(
|
|
776
|
+
context.release_id,
|
|
777
|
+
status=DeploymentState.ROLLED_BACK,
|
|
778
|
+
production_changed=context.production_may_have_changed,
|
|
779
|
+
traffic_activated=False,
|
|
780
|
+
traffic_hooks=tuple(context.hook_evidence),
|
|
781
|
+
health_checks=tuple(context.health_evidence),
|
|
782
|
+
failure=failure,
|
|
783
|
+
)
|
|
784
|
+
operation = context.store.transition(
|
|
785
|
+
context.operation_id,
|
|
786
|
+
status=OperationStatus.FAILED_SAFE,
|
|
787
|
+
stage="rolled_back",
|
|
788
|
+
message="The previous application and database were restored and verified.",
|
|
789
|
+
evidence={"rollback_verification": verification},
|
|
790
|
+
safety=SafetyFacts(
|
|
791
|
+
production_changed=context.production_may_have_changed,
|
|
792
|
+
previous_application_running=True,
|
|
793
|
+
recovery_required=False,
|
|
794
|
+
),
|
|
795
|
+
failure=failure,
|
|
796
|
+
)
|
|
797
|
+
return DeploymentResult(release=release, operation=operation)
|
|
798
|
+
except BaseException as rollback_error:
|
|
799
|
+
normalized = _normalize_deployment_error(rollback_error, context)
|
|
800
|
+
if normalized is None:
|
|
801
|
+
raise
|
|
802
|
+
recovery = RecoveryRequiredError(
|
|
803
|
+
"pre-traffic rollback could not be proven: "
|
|
804
|
+
+ normalized.payload.what_failed
|
|
805
|
+
+ "; original deployment failure: "
|
|
806
|
+
+ original.payload.what_failed,
|
|
807
|
+
production_changed=context.production_may_have_changed,
|
|
808
|
+
previous_application_running=False,
|
|
809
|
+
log_path=context.operation_log,
|
|
810
|
+
next_safe_action=(
|
|
811
|
+
"Keep traffic blocked. Preserve all evidence and manually verify the database, "
|
|
812
|
+
"previous container, old target, and maintenance state."
|
|
813
|
+
),
|
|
814
|
+
)
|
|
815
|
+
_finish_recovery_required(context, recovery)
|
|
816
|
+
raise recovery from None
|
|
817
|
+
|
|
818
|
+
|
|
819
|
+
def _verify_previous(context: _Context) -> dict[str, JsonValue]:
|
|
820
|
+
if context.previous is None:
|
|
821
|
+
raise RecoveryRequiredError(
|
|
822
|
+
"previous application identity is unavailable for health verification",
|
|
823
|
+
production_changed=context.production_may_have_changed,
|
|
824
|
+
previous_application_running=None,
|
|
825
|
+
log_path=context.operation_log,
|
|
826
|
+
next_safe_action="Inspect the current production container manually.",
|
|
827
|
+
)
|
|
828
|
+
database = verify_sqlite_database(context.loaded.config.database.path)
|
|
829
|
+
version = context.previous.version or "previous"
|
|
830
|
+
health = context.dependencies.health.check_application(
|
|
831
|
+
version=version,
|
|
832
|
+
database_path=context.loaded.config.database.path,
|
|
833
|
+
cancellation_event=context.cancellation_event,
|
|
834
|
+
)
|
|
835
|
+
context.health_evidence.append(_health_summary(health, role="previous", version=version))
|
|
836
|
+
evidence: dict[str, JsonValue] = {
|
|
837
|
+
"database": cast(JsonValue, database.model_dump(mode="json")),
|
|
838
|
+
"application_health": health.as_evidence(),
|
|
839
|
+
}
|
|
840
|
+
context.store.append_event(
|
|
841
|
+
context.operation_id,
|
|
842
|
+
message="Previous database and application health were verified.",
|
|
843
|
+
evidence={"previous_health": evidence},
|
|
844
|
+
)
|
|
845
|
+
return evidence
|
|
846
|
+
|
|
847
|
+
|
|
848
|
+
def _persist_stage(
|
|
849
|
+
context: _Context,
|
|
850
|
+
status: DeploymentState,
|
|
851
|
+
*,
|
|
852
|
+
message: str,
|
|
853
|
+
evidence: Mapping[str, object],
|
|
854
|
+
safety: SafetyFacts,
|
|
855
|
+
**release_changes: object,
|
|
856
|
+
) -> None:
|
|
857
|
+
release_changes.setdefault("traffic_hooks", tuple(context.hook_evidence))
|
|
858
|
+
release_changes.setdefault("health_checks", tuple(context.health_evidence))
|
|
859
|
+
context.store.transition(
|
|
860
|
+
context.operation_id,
|
|
861
|
+
status=OperationStatus.IN_PROGRESS,
|
|
862
|
+
stage=status.value,
|
|
863
|
+
message=message,
|
|
864
|
+
evidence=evidence,
|
|
865
|
+
safety=safety,
|
|
866
|
+
)
|
|
867
|
+
context.releases.transition(
|
|
868
|
+
context.release_id,
|
|
869
|
+
status=status,
|
|
870
|
+
**release_changes,
|
|
871
|
+
)
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
def _finish_failed_before_rollback(context: _Context, error: DployDBError) -> None:
|
|
875
|
+
status = (
|
|
876
|
+
DeploymentState.RECOVERY_REQUIRED
|
|
877
|
+
if error.payload.recovery_required
|
|
878
|
+
else DeploymentState.FAILED_SAFE
|
|
879
|
+
)
|
|
880
|
+
operation_status = (
|
|
881
|
+
OperationStatus.RECOVERY_REQUIRED
|
|
882
|
+
if error.payload.recovery_required
|
|
883
|
+
else OperationStatus.FAILED_SAFE
|
|
884
|
+
)
|
|
885
|
+
failure = _failure_record(error)
|
|
886
|
+
context.releases.transition(
|
|
887
|
+
context.release_id,
|
|
888
|
+
status=status,
|
|
889
|
+
production_changed=error.payload.production_changed,
|
|
890
|
+
traffic_activated=False,
|
|
891
|
+
traffic_hooks=tuple(context.hook_evidence),
|
|
892
|
+
health_checks=tuple(context.health_evidence),
|
|
893
|
+
failure=failure,
|
|
894
|
+
)
|
|
895
|
+
context.store.transition(
|
|
896
|
+
context.operation_id,
|
|
897
|
+
status=operation_status,
|
|
898
|
+
stage=status.value,
|
|
899
|
+
message="Deployment stopped before a cutover rollback was required.",
|
|
900
|
+
safety=SafetyFacts(
|
|
901
|
+
production_changed=error.payload.production_changed,
|
|
902
|
+
previous_application_running=error.payload.previous_application_running,
|
|
903
|
+
recovery_required=error.payload.recovery_required,
|
|
904
|
+
),
|
|
905
|
+
failure=failure,
|
|
906
|
+
)
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
def _finish_recovery_required(context: _Context, error: DployDBError) -> None:
|
|
910
|
+
failure = _failure_record(error)
|
|
911
|
+
try:
|
|
912
|
+
manifest = context.releases.read_manifest(context.release_id)
|
|
913
|
+
if manifest.status not in {
|
|
914
|
+
DeploymentState.ACTIVE,
|
|
915
|
+
DeploymentState.ROLLED_BACK,
|
|
916
|
+
DeploymentState.FAILED_SAFE,
|
|
917
|
+
DeploymentState.RECOVERY_REQUIRED,
|
|
918
|
+
}:
|
|
919
|
+
context.releases.transition(
|
|
920
|
+
context.release_id,
|
|
921
|
+
status=DeploymentState.RECOVERY_REQUIRED,
|
|
922
|
+
production_changed=(
|
|
923
|
+
error.payload.production_changed or context.production_may_have_changed
|
|
924
|
+
),
|
|
925
|
+
traffic_activated=context.traffic_activated,
|
|
926
|
+
traffic_hooks=tuple(context.hook_evidence),
|
|
927
|
+
health_checks=tuple(context.health_evidence),
|
|
928
|
+
failure=failure,
|
|
929
|
+
)
|
|
930
|
+
except Exception as release_error:
|
|
931
|
+
error.add_note(
|
|
932
|
+
context.loaded.secrets.redact_text(
|
|
933
|
+
"Release recovery evidence also failed: "
|
|
934
|
+
f"{type(release_error).__name__}: {release_error}"
|
|
935
|
+
)
|
|
936
|
+
)
|
|
937
|
+
try:
|
|
938
|
+
operation = context.store.read_manifest(context.operation_id)
|
|
939
|
+
if operation.status is OperationStatus.IN_PROGRESS:
|
|
940
|
+
context.store.transition(
|
|
941
|
+
context.operation_id,
|
|
942
|
+
status=OperationStatus.RECOVERY_REQUIRED,
|
|
943
|
+
stage="recovery_required",
|
|
944
|
+
message="Deployment state requires manual recovery.",
|
|
945
|
+
safety=SafetyFacts(
|
|
946
|
+
production_changed=(
|
|
947
|
+
error.payload.production_changed or context.production_may_have_changed
|
|
948
|
+
),
|
|
949
|
+
previous_application_running=error.payload.previous_application_running,
|
|
950
|
+
recovery_required=True,
|
|
951
|
+
),
|
|
952
|
+
failure=failure,
|
|
953
|
+
)
|
|
954
|
+
except Exception as state_error:
|
|
955
|
+
error.add_note(
|
|
956
|
+
context.loaded.secrets.redact_text(
|
|
957
|
+
"Operation recovery evidence also failed: "
|
|
958
|
+
f"{type(state_error).__name__}: {state_error}"
|
|
959
|
+
)
|
|
960
|
+
)
|
|
961
|
+
|
|
962
|
+
|
|
963
|
+
def _finish_operation_without_release(
|
|
964
|
+
store: StateStore,
|
|
965
|
+
operation_id: str,
|
|
966
|
+
error: DployDBError,
|
|
967
|
+
) -> None:
|
|
968
|
+
current = store.read_manifest(operation_id)
|
|
969
|
+
if current.status is not OperationStatus.IN_PROGRESS:
|
|
970
|
+
return
|
|
971
|
+
status = (
|
|
972
|
+
OperationStatus.RECOVERY_REQUIRED
|
|
973
|
+
if error.payload.recovery_required
|
|
974
|
+
else OperationStatus.FAILED_SAFE
|
|
975
|
+
)
|
|
976
|
+
store.transition(
|
|
977
|
+
operation_id,
|
|
978
|
+
status=status,
|
|
979
|
+
stage=status.value,
|
|
980
|
+
message="Deployment failed before its release manifest was created.",
|
|
981
|
+
safety=SafetyFacts(
|
|
982
|
+
production_changed=error.payload.production_changed,
|
|
983
|
+
previous_application_running=error.payload.previous_application_running,
|
|
984
|
+
recovery_required=error.payload.recovery_required,
|
|
985
|
+
),
|
|
986
|
+
failure=_failure_record(error),
|
|
987
|
+
)
|
|
988
|
+
|
|
989
|
+
|
|
990
|
+
def _normalize_deployment_error(
|
|
991
|
+
error: BaseException,
|
|
992
|
+
context: _Context,
|
|
993
|
+
) -> DployDBError | None:
|
|
994
|
+
if isinstance(error, DployDBError):
|
|
995
|
+
if context.production_may_have_changed and not error.payload.production_changed:
|
|
996
|
+
return type(error)(
|
|
997
|
+
error.payload.what_failed,
|
|
998
|
+
production_changed=True,
|
|
999
|
+
previous_application_running=False,
|
|
1000
|
+
log_path=error.payload.log_path or context.operation_log,
|
|
1001
|
+
next_safe_action=error.payload.next_safe_action,
|
|
1002
|
+
)
|
|
1003
|
+
return error
|
|
1004
|
+
if isinstance(error, ProductionStartError):
|
|
1005
|
+
start_error_type = (
|
|
1006
|
+
OperationFailedError if error.cleanup_proven is True else RecoveryRequiredError
|
|
1007
|
+
)
|
|
1008
|
+
return start_error_type(
|
|
1009
|
+
str(error),
|
|
1010
|
+
production_changed=True,
|
|
1011
|
+
previous_application_running=False,
|
|
1012
|
+
log_path=context.operation_log,
|
|
1013
|
+
next_safe_action=(
|
|
1014
|
+
"Keep maintenance enabled and inspect the operation-labeled new release "
|
|
1015
|
+
"before database rollback."
|
|
1016
|
+
),
|
|
1017
|
+
)
|
|
1018
|
+
if isinstance(error, ProductionCleanupError):
|
|
1019
|
+
return RecoveryRequiredError(
|
|
1020
|
+
str(error),
|
|
1021
|
+
production_changed=context.production_may_have_changed,
|
|
1022
|
+
previous_application_running=False,
|
|
1023
|
+
log_path=context.operation_log,
|
|
1024
|
+
next_safe_action="Keep traffic blocked and inspect the failed new release.",
|
|
1025
|
+
)
|
|
1026
|
+
if isinstance(error, ProductionInspectionError):
|
|
1027
|
+
return RecoveryRequiredError(
|
|
1028
|
+
str(error),
|
|
1029
|
+
production_changed=context.production_may_have_changed,
|
|
1030
|
+
previous_application_running=None,
|
|
1031
|
+
log_path=context.operation_log,
|
|
1032
|
+
next_safe_action="Preserve the contradictory container inspection evidence.",
|
|
1033
|
+
)
|
|
1034
|
+
if isinstance(error, ProductionRunnerError):
|
|
1035
|
+
if error.command is not None and error.command.outcome is CommandOutcome.CLEANUP_FAILED:
|
|
1036
|
+
runner_error_type: type[DployDBError] = RecoveryRequiredError
|
|
1037
|
+
else:
|
|
1038
|
+
runner_error_type = OperationFailedError
|
|
1039
|
+
return runner_error_type(
|
|
1040
|
+
str(error),
|
|
1041
|
+
production_changed=context.production_may_have_changed,
|
|
1042
|
+
previous_application_running=(False if context.stopped is not None else None),
|
|
1043
|
+
log_path=context.operation_log,
|
|
1044
|
+
next_safe_action="Keep maintenance enabled and inspect the recorded container state.",
|
|
1045
|
+
)
|
|
1046
|
+
if isinstance(error, ReadinessCheckError):
|
|
1047
|
+
return OperationFailedError(
|
|
1048
|
+
error.evidence.reason,
|
|
1049
|
+
production_changed=context.production_may_have_changed,
|
|
1050
|
+
previous_application_running=False,
|
|
1051
|
+
log_path=context.operation_log,
|
|
1052
|
+
next_safe_action="Keep traffic blocked and roll back the checked release.",
|
|
1053
|
+
)
|
|
1054
|
+
if isinstance(error, SmokeCheckError):
|
|
1055
|
+
error_type = OperationFailedError if error.cleanup_proven else RecoveryRequiredError
|
|
1056
|
+
return error_type(
|
|
1057
|
+
str(error),
|
|
1058
|
+
production_changed=context.production_may_have_changed,
|
|
1059
|
+
previous_application_running=False,
|
|
1060
|
+
log_path=context.operation_log,
|
|
1061
|
+
next_safe_action="Keep traffic blocked and inspect smoke-command cleanup evidence.",
|
|
1062
|
+
)
|
|
1063
|
+
if not isinstance(error, Exception):
|
|
1064
|
+
return None
|
|
1065
|
+
return RecoveryRequiredError(
|
|
1066
|
+
"unexpected deployment boundary failure: "
|
|
1067
|
+
+ context.loaded.secrets.redact_text(f"{type(error).__name__}: {error}"),
|
|
1068
|
+
production_changed=context.production_may_have_changed,
|
|
1069
|
+
previous_application_running=(False if context.stopped is not None else None),
|
|
1070
|
+
log_path=context.operation_log,
|
|
1071
|
+
next_safe_action="Preserve all evidence and inspect production before recovery.",
|
|
1072
|
+
)
|
|
1073
|
+
|
|
1074
|
+
|
|
1075
|
+
def _hook_error(
|
|
1076
|
+
result: TrafficHookResult,
|
|
1077
|
+
context: _Context,
|
|
1078
|
+
*,
|
|
1079
|
+
production_changed: bool,
|
|
1080
|
+
recovery: bool = False,
|
|
1081
|
+
) -> DployDBError:
|
|
1082
|
+
error_type: type[DployDBError]
|
|
1083
|
+
if recovery or result.command.outcome is CommandOutcome.CLEANUP_FAILED:
|
|
1084
|
+
error_type = RecoveryRequiredError
|
|
1085
|
+
else:
|
|
1086
|
+
error_type = ExternalCommandError
|
|
1087
|
+
return error_type(
|
|
1088
|
+
_hook_description(result),
|
|
1089
|
+
production_changed=production_changed,
|
|
1090
|
+
previous_application_running=(False if context.stopped is not None else True),
|
|
1091
|
+
log_path=context.operation_log,
|
|
1092
|
+
next_safe_action="Keep traffic blocked and inspect the recorded hook evidence.",
|
|
1093
|
+
)
|
|
1094
|
+
|
|
1095
|
+
|
|
1096
|
+
def _hook_may_have_run(result: TrafficHookResult) -> bool:
|
|
1097
|
+
return not (
|
|
1098
|
+
result.command.outcome is CommandOutcome.START_FAILED
|
|
1099
|
+
or (result.command.outcome is CommandOutcome.CANCELLED and result.command.exit_code is None)
|
|
1100
|
+
)
|
|
1101
|
+
|
|
1102
|
+
|
|
1103
|
+
def _record_hook(context: _Context, result: TrafficHookResult) -> None:
|
|
1104
|
+
context.hook_evidence.append(_hook_summary(result))
|
|
1105
|
+
context.store.append_event(
|
|
1106
|
+
context.operation_id,
|
|
1107
|
+
message=f"Traffic hook {result.action.value} reached a terminal outcome.",
|
|
1108
|
+
evidence={"traffic_hook": result.as_evidence()},
|
|
1109
|
+
)
|
|
1110
|
+
|
|
1111
|
+
|
|
1112
|
+
def _hook_description(result: TrafficHookResult) -> str:
|
|
1113
|
+
command = result.command
|
|
1114
|
+
if command.stdout.truncated or command.stderr.truncated:
|
|
1115
|
+
detail = "complete output evidence was unavailable"
|
|
1116
|
+
elif command.outcome is CommandOutcome.NONZERO_EXIT:
|
|
1117
|
+
detail = f"exited with status {command.exit_code}"
|
|
1118
|
+
elif command.outcome is CommandOutcome.TIMED_OUT:
|
|
1119
|
+
detail = "timed out"
|
|
1120
|
+
elif command.outcome is CommandOutcome.CANCELLED:
|
|
1121
|
+
detail = "was cancelled"
|
|
1122
|
+
elif command.outcome is CommandOutcome.CLEANUP_FAILED:
|
|
1123
|
+
detail = "process cleanup could not be proven"
|
|
1124
|
+
elif command.outcome is CommandOutcome.START_FAILED:
|
|
1125
|
+
detail = "could not start"
|
|
1126
|
+
else:
|
|
1127
|
+
detail = "returned invalid success evidence"
|
|
1128
|
+
return f"traffic hook {result.action.value} {detail}"
|
|
1129
|
+
|
|
1130
|
+
|
|
1131
|
+
def _require_complete_logs(logs: ProductionLogs, log_path: Path) -> None:
|
|
1132
|
+
command = logs.command
|
|
1133
|
+
if (
|
|
1134
|
+
command.outcome is CommandOutcome.SUCCEEDED
|
|
1135
|
+
and not command.stdout.truncated
|
|
1136
|
+
and not command.stderr.truncated
|
|
1137
|
+
):
|
|
1138
|
+
return
|
|
1139
|
+
error_type = (
|
|
1140
|
+
RecoveryRequiredError
|
|
1141
|
+
if command.outcome is CommandOutcome.CLEANUP_FAILED
|
|
1142
|
+
else OperationFailedError
|
|
1143
|
+
)
|
|
1144
|
+
raise error_type(
|
|
1145
|
+
"new production application logs were not captured completely",
|
|
1146
|
+
production_changed=True,
|
|
1147
|
+
previous_application_running=False,
|
|
1148
|
+
log_path=log_path,
|
|
1149
|
+
next_safe_action="Keep traffic blocked and inspect the new application directly.",
|
|
1150
|
+
)
|
|
1151
|
+
|
|
1152
|
+
|
|
1153
|
+
def _failure_record(error: DployDBError) -> FailureRecord:
|
|
1154
|
+
return FailureRecord(
|
|
1155
|
+
error_code=error.payload.error_code,
|
|
1156
|
+
what_failed=error.payload.what_failed,
|
|
1157
|
+
log_path=error.payload.log_path,
|
|
1158
|
+
next_safe_action=error.payload.next_safe_action,
|
|
1159
|
+
)
|
|
1160
|
+
|
|
1161
|
+
|
|
1162
|
+
def _no_fault(_stage: str) -> None:
|
|
1163
|
+
return
|