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/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