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/candidate.py ADDED
@@ -0,0 +1,868 @@
1
+ """Durable rehearsal-plus-candidate validation with production left untouched."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import threading
7
+ from collections.abc import Callable, Mapping
8
+ from dataclasses import dataclass
9
+ from datetime import datetime
10
+ from pathlib import Path
11
+ from typing import Protocol, cast
12
+
13
+ from dploydb.backup import create_verified_backup
14
+ from dploydb.config import LoadedConfiguration, configuration_fingerprint
15
+ from dploydb.errors import (
16
+ DployDBError,
17
+ ExternalCommandError,
18
+ OperationFailedError,
19
+ RecoveryRequiredError,
20
+ SafetyCheckError,
21
+ )
22
+ from dploydb.health import (
23
+ CandidateHealthChecker,
24
+ CandidateHealthResult,
25
+ ReadinessCheckError,
26
+ SmokeCheckError,
27
+ )
28
+ from dploydb.locking import DeploymentLock
29
+ from dploydb.migration import (
30
+ MIGRATION_MAX_OUTPUT_BYTES,
31
+ WORKSPACE_DIRECTORY_NAME,
32
+ ActiveMigrationRehearsal,
33
+ MigrationWorkspaceCleanupError,
34
+ migration_rehearsal,
35
+ require_clean_operation_state,
36
+ )
37
+ from dploydb.models import (
38
+ BackupPurpose,
39
+ DeploymentState,
40
+ FailureRecord,
41
+ MigrationCommandEvidence,
42
+ MigrationRehearsalResult,
43
+ OperationStatus,
44
+ SafetyFacts,
45
+ serialize_utc_timestamp,
46
+ utc_now,
47
+ )
48
+ from dploydb.redaction import JsonValue, SecretRegistry
49
+ from dploydb.runners.base import (
50
+ ApplicationRunner,
51
+ CandidateCleanup,
52
+ CandidateCleanupError,
53
+ CandidateInspection,
54
+ CandidateInspectionError,
55
+ CandidateLogs,
56
+ CandidateRunnerError,
57
+ CandidateStart,
58
+ CandidateStartError,
59
+ validate_release_identifier,
60
+ )
61
+ from dploydb.runners.docker_compose import DockerComposeCandidateRunner
62
+ from dploydb.sqlite_checks import verify_sqlite_database
63
+ from dploydb.state import StateStore
64
+ from dploydb.storage.local import LocalBackupStorage
65
+ from dploydb.subprocesses import CommandOutcome, CommandResult, SubprocessRunner
66
+
67
+
68
+ class HealthChecker(Protocol):
69
+ """Small injectable health boundary consumed by the candidate coordinator."""
70
+
71
+ def check(
72
+ self,
73
+ *,
74
+ version: str,
75
+ rehearsal_database_path: Path,
76
+ cancellation_event: threading.Event | None = None,
77
+ ) -> CandidateHealthResult: ...
78
+
79
+
80
+ CandidateStageObserver = Callable[
81
+ [DeploymentState, Mapping[str, JsonValue]],
82
+ None,
83
+ ]
84
+
85
+
86
+ @dataclass(frozen=True, slots=True)
87
+ class CandidateValidationResult:
88
+ """Complete passing evidence returned only after every cleanup proof passes."""
89
+
90
+ operation_id: str
91
+ version: str
92
+ rehearsal: MigrationRehearsalResult
93
+ health: CandidateHealthResult
94
+ logs: CandidateLogs
95
+ cleanup: CandidateCleanup
96
+ completed_at: datetime
97
+
98
+ def as_evidence(self) -> dict[str, JsonValue]:
99
+ return {
100
+ "operation_id": self.operation_id,
101
+ "version": self.version,
102
+ "rehearsal": self.rehearsal.model_dump(mode="json"),
103
+ "health": self.health.as_evidence(),
104
+ "logs": self.logs.command.as_evidence(),
105
+ "cleanup": _cleanup_evidence(self.cleanup),
106
+ "completed_at": serialize_utc_timestamp(self.completed_at),
107
+ }
108
+
109
+
110
+ @dataclass(frozen=True, slots=True)
111
+ class _RuntimeResult:
112
+ health: CandidateHealthResult
113
+ logs: CandidateLogs
114
+ cleanup: CandidateCleanup
115
+
116
+
117
+ def validate_configured_candidate(
118
+ loaded: LoadedConfiguration,
119
+ *,
120
+ version: str,
121
+ config_path: Path,
122
+ command_environment: Mapping[str, str] | None = None,
123
+ command_runner: SubprocessRunner | None = None,
124
+ application_runner: ApplicationRunner | None = None,
125
+ health_checker: HealthChecker | None = None,
126
+ cancellation_event: threading.Event | None = None,
127
+ ) -> CandidateValidationResult:
128
+ """Rehearse and validate one candidate under a single durable operation."""
129
+ release = validate_release_identifier(version)
130
+ config = loaded.config
131
+ secrets = loaded.secrets
132
+ store = StateStore(config.state_directory, secrets=secrets)
133
+ lock = DeploymentLock(config.state_directory, secrets=secrets)
134
+
135
+ with lock:
136
+ require_clean_operation_state(lock, store)
137
+ operation = store.create_operation(
138
+ operation_type="candidate_validation",
139
+ project=config.project,
140
+ configuration_fingerprint=configuration_fingerprint(config, secrets=secrets),
141
+ evidence={
142
+ "database_path": str(config.database.path),
143
+ "requested_version": release,
144
+ },
145
+ )
146
+ lock.record_owner(
147
+ operation_id=operation.operation_id,
148
+ operation_type="candidate_validation",
149
+ )
150
+ try:
151
+ return run_candidate_stage(
152
+ loaded,
153
+ version=release,
154
+ config_path=config_path,
155
+ operation_id=operation.operation_id,
156
+ store=store,
157
+ lock=lock,
158
+ command_environment=command_environment,
159
+ command_runner=command_runner,
160
+ application_runner=application_runner,
161
+ health_checker=health_checker,
162
+ cancellation_event=cancellation_event,
163
+ complete_operation=True,
164
+ )
165
+ except DployDBError as error:
166
+ _finish_failure(store, operation.operation_id, error)
167
+ raise
168
+ except Exception as raw_error:
169
+ operation_log = store.operation_paths(operation.operation_id).events
170
+ unexpected_failure = OperationFailedError(
171
+ "candidate validation failed safely: "
172
+ + secrets.redact_text(f"{type(raw_error).__name__}: {raw_error}"),
173
+ production_changed=False,
174
+ previous_application_running=None,
175
+ log_path=operation_log,
176
+ next_safe_action=(
177
+ "Production was not changed; inspect the candidate event log, correct the "
178
+ "failure, and retry."
179
+ ),
180
+ )
181
+ _finish_failure(store, operation.operation_id, unexpected_failure)
182
+ raise unexpected_failure from None
183
+
184
+
185
+ def run_candidate_stage(
186
+ loaded: LoadedConfiguration,
187
+ *,
188
+ version: str,
189
+ config_path: Path,
190
+ operation_id: str,
191
+ store: StateStore,
192
+ lock: DeploymentLock,
193
+ command_environment: Mapping[str, str] | None = None,
194
+ command_runner: SubprocessRunner | None = None,
195
+ application_runner: ApplicationRunner | None = None,
196
+ health_checker: HealthChecker | None = None,
197
+ cancellation_event: threading.Event | None = None,
198
+ complete_operation: bool = False,
199
+ stage_observer: CandidateStageObserver | None = None,
200
+ ) -> CandidateValidationResult:
201
+ """Run the production-read-only pre-cutover stage in a caller-owned operation."""
202
+ release = validate_release_identifier(version)
203
+ config = loaded.config
204
+ secrets = loaded.secrets
205
+ _require_candidate_stage_owner(
206
+ loaded,
207
+ operation_id=operation_id,
208
+ store=store,
209
+ lock=lock,
210
+ )
211
+ operation_log = store.operation_paths(operation_id).events
212
+ environment = dict(os.environ if command_environment is None else command_environment)
213
+ working_directory = config_path.resolve().parent
214
+ runner = command_runner or SubprocessRunner(
215
+ secrets=secrets,
216
+ max_output_bytes=MIGRATION_MAX_OUTPUT_BYTES,
217
+ )
218
+ owned_health: CandidateHealthChecker | None = None
219
+
220
+ try:
221
+ preflight = verify_sqlite_database(config.database.path)
222
+ store.transition(
223
+ operation_id,
224
+ status=OperationStatus.IN_PROGRESS,
225
+ stage="preflight_passed",
226
+ message="Production SQLite preflight passed without mutation.",
227
+ evidence=preflight.model_dump(mode="json"),
228
+ )
229
+ _notify_stage(
230
+ stage_observer,
231
+ DeploymentState.PREFLIGHT_PASSED,
232
+ preflight.model_dump(mode="json"),
233
+ )
234
+ snapshot = create_verified_backup(
235
+ config.database.path,
236
+ project=secrets.redact_text(config.project),
237
+ purpose=BackupPurpose.REHEARSAL,
238
+ storage=LocalBackupStorage(config.backup.local_directory),
239
+ operation_id=operation_id,
240
+ metadata_source_path=_safe_metadata_path(config.database.path, loaded),
241
+ )
242
+ snapshot_evidence: dict[str, JsonValue] = {
243
+ "backup_id": snapshot.metadata.backup_id,
244
+ "backup_path": str(snapshot.database_path),
245
+ "sha256": snapshot.metadata.sha256,
246
+ "size_bytes": snapshot.metadata.size_bytes,
247
+ }
248
+ store.transition(
249
+ operation_id,
250
+ status=OperationStatus.IN_PROGRESS,
251
+ stage="snapshot_verified",
252
+ message="Verified candidate rehearsal snapshot completed.",
253
+ evidence=snapshot_evidence,
254
+ )
255
+ _notify_stage(
256
+ stage_observer,
257
+ DeploymentState.SNAPSHOT_VERIFIED,
258
+ snapshot_evidence,
259
+ )
260
+
261
+ def record_migration_command(evidence: MigrationCommandEvidence) -> None:
262
+ store.append_event(
263
+ operation_id,
264
+ message="Candidate migration command reached a terminal outcome.",
265
+ evidence={"migration_command": evidence.model_dump(mode="json")},
266
+ )
267
+
268
+ selected_runner = application_runner or DockerComposeCandidateRunner(
269
+ project=config.project,
270
+ application=config.application,
271
+ database_environment_name=config.database.path_env,
272
+ production_database_path=config.database.path,
273
+ secrets=secrets,
274
+ working_directory=working_directory,
275
+ command_environment=environment,
276
+ command_runner=runner,
277
+ )
278
+ selected_health = health_checker
279
+ if selected_health is None:
280
+ owned_health = CandidateHealthChecker(
281
+ application=config.application,
282
+ database_environment_name=config.database.path_env,
283
+ secrets=secrets,
284
+ working_directory=working_directory,
285
+ command_environment=environment,
286
+ command_runner=runner,
287
+ )
288
+ selected_health = owned_health
289
+
290
+ with migration_rehearsal(
291
+ snapshot,
292
+ operation_id=operation_id,
293
+ command=config.migration.command,
294
+ database_environment_name=config.database.path_env,
295
+ timeout_seconds=config.migration.timeout_seconds,
296
+ workspace_root=config.state_directory / WORKSPACE_DIRECTORY_NAME,
297
+ working_directory=working_directory,
298
+ environment=environment,
299
+ runner=runner,
300
+ command_evidence_sink=record_migration_command,
301
+ cancellation_event=cancellation_event,
302
+ log_path=operation_log,
303
+ ) as active:
304
+ rehearsal_evidence = _rehearsal_summary(active.result)
305
+ store.transition(
306
+ operation_id,
307
+ status=OperationStatus.IN_PROGRESS,
308
+ stage="rehearsal_passed",
309
+ message="Migration rehearsal passed; candidate validation may begin.",
310
+ evidence=rehearsal_evidence,
311
+ )
312
+ _notify_stage(
313
+ stage_observer,
314
+ DeploymentState.REHEARSAL_PASSED,
315
+ rehearsal_evidence,
316
+ )
317
+ runtime = _validate_runtime(
318
+ runner=selected_runner,
319
+ health_checker=selected_health,
320
+ active=active,
321
+ operation_id=operation_id,
322
+ version=release,
323
+ store=store,
324
+ operation_log=operation_log,
325
+ cancellation_event=cancellation_event,
326
+ )
327
+ if owned_health is not None:
328
+ owned_health.close()
329
+ owned_health = None
330
+
331
+ result = CandidateValidationResult(
332
+ operation_id=operation_id,
333
+ version=release,
334
+ rehearsal=active.result,
335
+ health=runtime.health,
336
+ logs=runtime.logs,
337
+ cleanup=runtime.cleanup,
338
+ completed_at=utc_now(),
339
+ )
340
+ terminal_status = (
341
+ OperationStatus.SUCCEEDED if complete_operation else OperationStatus.IN_PROGRESS
342
+ )
343
+ candidate_evidence: dict[str, JsonValue] = {
344
+ **_rehearsal_summary(result.rehearsal),
345
+ "requested_version": release,
346
+ "readiness_attempts": result.health.readiness.attempt_count,
347
+ "smoke_outcome": (
348
+ None if result.health.smoke is None else result.health.smoke.outcome.value
349
+ ),
350
+ "candidate_cleanup_proven": result.cleanup.proof.proven,
351
+ "rehearsal_workspace_cleaned": True,
352
+ }
353
+ store.transition(
354
+ operation_id,
355
+ status=terminal_status,
356
+ stage="candidate_healthy",
357
+ message="Candidate checks and candidate/rehearsal cleanup completed successfully.",
358
+ evidence=candidate_evidence,
359
+ safety=SafetyFacts(
360
+ production_changed=False,
361
+ previous_application_running=None,
362
+ recovery_required=False,
363
+ ),
364
+ )
365
+ _notify_stage(
366
+ stage_observer,
367
+ DeploymentState.CANDIDATE_HEALTHY,
368
+ candidate_evidence,
369
+ )
370
+ return result
371
+ except MigrationWorkspaceCleanupError as error:
372
+ raise RecoveryRequiredError(
373
+ error.payload.what_failed,
374
+ production_changed=False,
375
+ previous_application_running=None,
376
+ log_path=operation_log,
377
+ next_safe_action=(
378
+ "Production was not changed. Preserve the operation evidence and remove only "
379
+ "the recorded private rehearsal workspace before recovery."
380
+ ),
381
+ ) from None
382
+ except DployDBError:
383
+ raise
384
+ except Exception as raw_error:
385
+ raise OperationFailedError(
386
+ "candidate validation failed safely: "
387
+ + secrets.redact_text(f"{type(raw_error).__name__}: {raw_error}"),
388
+ production_changed=False,
389
+ previous_application_running=None,
390
+ log_path=operation_log,
391
+ next_safe_action=(
392
+ "Production was not changed; inspect the candidate event log, correct the "
393
+ "failure, and retry."
394
+ ),
395
+ ) from None
396
+ finally:
397
+ if owned_health is not None:
398
+ owned_health.close()
399
+
400
+
401
+ def _require_candidate_stage_owner(
402
+ loaded: LoadedConfiguration,
403
+ *,
404
+ operation_id: str,
405
+ store: StateStore,
406
+ lock: DeploymentLock,
407
+ ) -> None:
408
+ config = loaded.config
409
+ if store.root != config.state_directory or lock.state_directory != config.state_directory:
410
+ raise ValueError("candidate stage state and lock must use the configured state directory")
411
+ if not lock.acquired or lock.owner is None or lock.owner.operation_id != operation_id:
412
+ raise SafetyCheckError(
413
+ "candidate stage requires the matching durable deployment lock owner",
414
+ production_changed=False,
415
+ previous_application_running=None,
416
+ log_path=lock.owner_path,
417
+ next_safe_action="Acquire and record the caller-owned operation before retrying.",
418
+ )
419
+ operation = store.read_manifest(operation_id)
420
+ expected_fingerprint = configuration_fingerprint(config, secrets=loaded.secrets)
421
+ if (
422
+ operation.status is not OperationStatus.IN_PROGRESS
423
+ or operation.stage != "created"
424
+ or operation.operation_type not in {"candidate_validation", "deploy"}
425
+ or operation.project != config.project
426
+ or operation.configuration_fingerprint != expected_fingerprint
427
+ ):
428
+ raise SafetyCheckError(
429
+ "candidate stage operation does not match a fresh configured deployment",
430
+ production_changed=False,
431
+ previous_application_running=None,
432
+ log_path=store.operation_paths(operation_id).events,
433
+ next_safe_action="Preserve the operation evidence and start a fresh deployment.",
434
+ )
435
+
436
+
437
+ def _validate_runtime(
438
+ *,
439
+ runner: ApplicationRunner,
440
+ health_checker: HealthChecker,
441
+ active: ActiveMigrationRehearsal,
442
+ operation_id: str,
443
+ version: str,
444
+ store: StateStore,
445
+ operation_log: Path,
446
+ cancellation_event: threading.Event | None,
447
+ ) -> _RuntimeResult:
448
+ started: CandidateStart | None = None
449
+ inspection: CandidateInspection | None = None
450
+ health: CandidateHealthResult | None = None
451
+ logs: CandidateLogs | None = None
452
+ cleanup: CandidateCleanup | None = None
453
+ primary: BaseException | None = None
454
+ logs_failure: DployDBError | None = None
455
+ cleanup_failure: DployDBError | None = None
456
+
457
+ try:
458
+ started = runner.start(
459
+ operation_id=operation_id,
460
+ version=version,
461
+ rehearsal_database_path=active.database_path,
462
+ cancellation_event=cancellation_event,
463
+ )
464
+ store.append_event(
465
+ operation_id,
466
+ message="Candidate Compose startup completed.",
467
+ evidence={"candidate_start": _start_evidence(started)},
468
+ )
469
+ inspection = runner.inspect(started.handle, cancellation_event=cancellation_event)
470
+ store.append_event(
471
+ operation_id,
472
+ message="Candidate live isolation inspection passed.",
473
+ evidence={"candidate_inspection": _inspection_evidence(inspection)},
474
+ )
475
+ health = health_checker.check(
476
+ version=version,
477
+ rehearsal_database_path=active.database_path,
478
+ cancellation_event=cancellation_event,
479
+ )
480
+ store.append_event(
481
+ operation_id,
482
+ message="Candidate HTTP readiness and optional smoke checks passed.",
483
+ evidence={"candidate_health": health.as_evidence()},
484
+ )
485
+ except BaseException as error:
486
+ primary = error
487
+ try:
488
+ store.append_event(
489
+ operation_id,
490
+ message="Candidate startup, inspection, or health checks were rejected.",
491
+ evidence={"candidate_failure": _primary_evidence(error)},
492
+ )
493
+ except BaseException as evidence_error:
494
+ primary = evidence_error
495
+
496
+ if started is not None:
497
+ try:
498
+ logs = runner.collect_logs(started.handle, cancellation_event=cancellation_event)
499
+ store.append_event(
500
+ operation_id,
501
+ message="Bounded candidate application logs were collected.",
502
+ evidence={"candidate_logs": logs.command.as_evidence()},
503
+ )
504
+ logs_failure = _logs_failure(logs.command, operation_log)
505
+ except BaseException as error:
506
+ if isinstance(error, DployDBError):
507
+ logs_failure = error
508
+ elif isinstance(error, Exception):
509
+ logs_failure = OperationFailedError(
510
+ "candidate log collection failed: "
511
+ + store.secrets.redact_text(f"{type(error).__name__}: {error}"),
512
+ production_changed=False,
513
+ previous_application_running=None,
514
+ log_path=operation_log,
515
+ next_safe_action=(
516
+ "Production was not changed; inspect Docker directly and retry after "
517
+ "correcting log collection."
518
+ ),
519
+ )
520
+ else:
521
+ primary = error
522
+
523
+ try:
524
+ cleanup = runner.stop(started.handle)
525
+ store.append_event(
526
+ operation_id,
527
+ message="Candidate container and isolated network cleanup was proven.",
528
+ evidence={"candidate_cleanup": _cleanup_evidence(cleanup)},
529
+ )
530
+ except CandidateCleanupError as error:
531
+ if error.cleanup is not None:
532
+ cleanup = error.cleanup
533
+ try:
534
+ store.append_event(
535
+ operation_id,
536
+ message="Candidate cleanup command reported a failure.",
537
+ evidence={"candidate_cleanup": _cleanup_evidence(cleanup)},
538
+ )
539
+ except BaseException as evidence_error:
540
+ cleanup_failure = _unknown_cleanup_failure(evidence_error, operation_log, store)
541
+ if cleanup_failure is None:
542
+ cleanup_failure = _candidate_cleanup_failure(error, operation_log)
543
+ except BaseException as error:
544
+ cleanup_failure = _unknown_cleanup_failure(error, operation_log, store)
545
+
546
+ normalized_primary = _normalize_primary(
547
+ primary,
548
+ operation_log=operation_log,
549
+ candidate_started=started is not None,
550
+ secrets=store.secrets,
551
+ )
552
+ failures = [
553
+ error for error in (normalized_primary, logs_failure, cleanup_failure) if error is not None
554
+ ]
555
+ if failures:
556
+ if len(failures) == 1:
557
+ raise failures[0]
558
+ raise _combined_failure(failures, operation_log)
559
+ if primary is not None:
560
+ raise primary
561
+ if started is None or inspection is None or health is None or logs is None or cleanup is None:
562
+ raise RecoveryRequiredError(
563
+ "candidate validation ended without a complete runtime evidence set",
564
+ production_changed=False,
565
+ previous_application_running=None,
566
+ log_path=operation_log,
567
+ next_safe_action="Preserve the event log and inspect candidate resources manually.",
568
+ )
569
+ return _RuntimeResult(health=health, logs=logs, cleanup=cleanup)
570
+
571
+
572
+ def _normalize_primary(
573
+ error: BaseException | None,
574
+ *,
575
+ operation_log: Path,
576
+ candidate_started: bool,
577
+ secrets: SecretRegistry,
578
+ ) -> DployDBError | None:
579
+ if error is None:
580
+ return None
581
+ if isinstance(error, DployDBError):
582
+ return error
583
+ if isinstance(error, CandidateStartError):
584
+ if error.cleanup_proven is not True:
585
+ return RecoveryRequiredError(
586
+ str(error),
587
+ production_changed=False,
588
+ previous_application_running=None,
589
+ log_path=operation_log,
590
+ next_safe_action=(
591
+ "Production was not changed. Confirm the operation-labeled candidate "
592
+ "container and network are gone before recovery."
593
+ ),
594
+ )
595
+ return ExternalCommandError(
596
+ str(error),
597
+ production_changed=False,
598
+ previous_application_running=None,
599
+ log_path=operation_log,
600
+ next_safe_action=(
601
+ "Production was not changed and candidate cleanup was proven; inspect startup "
602
+ "evidence, correct the release, and retry."
603
+ ),
604
+ )
605
+ if isinstance(error, CandidateInspectionError):
606
+ return RecoveryRequiredError(
607
+ str(error),
608
+ production_changed=False,
609
+ previous_application_running=None,
610
+ log_path=operation_log,
611
+ next_safe_action=(
612
+ "Production was not changed. Preserve the contradictory inspection evidence "
613
+ "and confirm candidate isolation before recovery."
614
+ ),
615
+ )
616
+ if isinstance(error, ReadinessCheckError):
617
+ return OperationFailedError(
618
+ error.evidence.reason,
619
+ production_changed=False,
620
+ previous_application_running=None,
621
+ log_path=operation_log,
622
+ next_safe_action=(
623
+ "Production was not changed and candidate cleanup was proven; inspect readiness "
624
+ "and application logs, correct the release, and retry."
625
+ ),
626
+ )
627
+ if isinstance(error, SmokeCheckError):
628
+ error_type = OperationFailedError if error.cleanup_proven else RecoveryRequiredError
629
+ return error_type(
630
+ str(error),
631
+ production_changed=False,
632
+ previous_application_running=None,
633
+ log_path=operation_log,
634
+ next_safe_action=(
635
+ "Production was not changed. Inspect smoke and cleanup evidence before retrying."
636
+ ),
637
+ )
638
+ if isinstance(error, CandidateRunnerError):
639
+ return RecoveryRequiredError(
640
+ str(error),
641
+ production_changed=False,
642
+ previous_application_running=None,
643
+ log_path=operation_log,
644
+ next_safe_action="Preserve the event log and inspect candidate resources manually.",
645
+ )
646
+ if not isinstance(error, Exception):
647
+ return None
648
+ detail = secrets.redact_text(f"{type(error).__name__}: {error}")
649
+ error_type = OperationFailedError if candidate_started else RecoveryRequiredError
650
+ return error_type(
651
+ "unexpected candidate runtime failure: " + detail,
652
+ production_changed=False,
653
+ previous_application_running=None,
654
+ log_path=operation_log,
655
+ next_safe_action=(
656
+ "Production was not changed; preserve the evidence and inspect candidate resources "
657
+ "before retrying."
658
+ ),
659
+ )
660
+
661
+
662
+ def _logs_failure(result: CommandResult, operation_log: Path) -> DployDBError | None:
663
+ if result.outcome is CommandOutcome.SUCCEEDED:
664
+ return None
665
+ if result.outcome is CommandOutcome.CLEANUP_FAILED:
666
+ return RecoveryRequiredError(
667
+ "candidate log-collection process cleanup could not be proven",
668
+ production_changed=False,
669
+ previous_application_running=None,
670
+ log_path=operation_log,
671
+ next_safe_action="Confirm the log process is gone before recovery.",
672
+ )
673
+ return OperationFailedError(
674
+ f"candidate log collection ended with {result.outcome.value}",
675
+ production_changed=False,
676
+ previous_application_running=None,
677
+ log_path=operation_log,
678
+ next_safe_action="Inspect Docker logs directly, correct the cause, and retry.",
679
+ )
680
+
681
+
682
+ def _candidate_cleanup_failure(
683
+ error: CandidateCleanupError,
684
+ operation_log: Path,
685
+ ) -> DployDBError:
686
+ error_type = OperationFailedError if error.cleanup_proven is True else RecoveryRequiredError
687
+ return error_type(
688
+ str(error),
689
+ production_changed=False,
690
+ previous_application_running=None,
691
+ log_path=operation_log,
692
+ next_safe_action=(
693
+ "Production was not changed. Inspect the exact candidate container and isolated "
694
+ "network evidence before retrying."
695
+ ),
696
+ )
697
+
698
+
699
+ def _unknown_cleanup_failure(
700
+ error: BaseException,
701
+ operation_log: Path,
702
+ store: StateStore,
703
+ ) -> DployDBError:
704
+ if isinstance(error, DployDBError):
705
+ return error
706
+ return RecoveryRequiredError(
707
+ "candidate cleanup could not be proven: "
708
+ + store.secrets.redact_text(f"{type(error).__name__}: {error}"),
709
+ production_changed=False,
710
+ previous_application_running=None,
711
+ log_path=operation_log,
712
+ next_safe_action=(
713
+ "Production was not changed. Confirm the operation-labeled candidate container and "
714
+ "network are absent before recovery."
715
+ ),
716
+ )
717
+
718
+
719
+ def _combined_failure(errors: list[DployDBError], operation_log: Path) -> DployDBError:
720
+ detail = "; ".join(error.payload.what_failed for error in errors)
721
+ error_type = (
722
+ RecoveryRequiredError
723
+ if any(error.payload.recovery_required for error in errors)
724
+ else OperationFailedError
725
+ )
726
+ return error_type(
727
+ detail,
728
+ production_changed=False,
729
+ previous_application_running=None,
730
+ log_path=operation_log,
731
+ next_safe_action=(
732
+ "Production was not changed. Resolve every recorded candidate, log, and cleanup "
733
+ "failure before retrying."
734
+ ),
735
+ )
736
+
737
+
738
+ def _primary_evidence(error: BaseException) -> dict[str, JsonValue]:
739
+ if isinstance(error, CandidateStartError):
740
+ return {
741
+ "kind": "candidate_start",
742
+ "message": str(error),
743
+ "command": None if error.command is None else error.command.as_evidence(),
744
+ "cleanup": None if error.cleanup is None else _cleanup_evidence(error.cleanup),
745
+ }
746
+ if isinstance(error, CandidateInspectionError):
747
+ return {
748
+ "kind": "candidate_inspection",
749
+ "message": str(error),
750
+ "command": None if error.command is None else error.command.as_evidence(),
751
+ }
752
+ if isinstance(error, ReadinessCheckError):
753
+ return {"kind": "readiness", "readiness": error.evidence.as_evidence()}
754
+ if isinstance(error, SmokeCheckError):
755
+ return {"kind": "smoke", **error.as_evidence()}
756
+ if isinstance(error, DployDBError):
757
+ return {
758
+ "kind": "dploydb",
759
+ "failure": cast(dict[str, JsonValue], error.payload.as_dict()),
760
+ }
761
+ return {"kind": type(error).__name__, "message": str(error)}
762
+
763
+
764
+ def _start_evidence(started: CandidateStart) -> dict[str, JsonValue]:
765
+ handle = started.handle
766
+ return {
767
+ "operation_id": handle.operation_id,
768
+ "version": handle.version,
769
+ "compose_project": handle.compose_project,
770
+ "container_name": handle.container_name,
771
+ "rehearsal_database_path": str(handle.rehearsal_database_path),
772
+ "candidate_database_path": handle.candidate_database_path,
773
+ "container_reference": started.container_reference,
774
+ "command": started.command.as_evidence(),
775
+ }
776
+
777
+
778
+ def _inspection_evidence(inspection: CandidateInspection) -> dict[str, JsonValue]:
779
+ return {
780
+ "container_id": inspection.container_id,
781
+ "container_name": inspection.container_name,
782
+ "running": inspection.running,
783
+ "compose_project": inspection.compose_project,
784
+ "compose_service": inspection.compose_service,
785
+ "operation_id": inspection.operation_id,
786
+ "host_ip": inspection.host_ip,
787
+ "host_port": inspection.host_port,
788
+ "container_port": inspection.container_port,
789
+ "mounts": [
790
+ {
791
+ "mount_type": mount.mount_type,
792
+ "source": mount.source,
793
+ "destination": mount.destination,
794
+ "read_write": mount.read_write,
795
+ }
796
+ for mount in inspection.mounts
797
+ ],
798
+ "command": inspection.command.as_evidence(),
799
+ }
800
+
801
+
802
+ def _cleanup_evidence(cleanup: CandidateCleanup) -> dict[str, JsonValue]:
803
+ proof = cleanup.proof
804
+ return {
805
+ "presence_query": cleanup.presence_query.as_evidence(),
806
+ "remove_command": (
807
+ None if cleanup.remove_command is None else cleanup.remove_command.as_evidence()
808
+ ),
809
+ "compose_down": cleanup.compose_down.as_evidence(),
810
+ "proof": {
811
+ "container_absent": proof.container_absent,
812
+ "networks_absent": proof.networks_absent,
813
+ "proven": proof.proven,
814
+ "container_query": proof.container_query.as_evidence(),
815
+ "network_query": proof.network_query.as_evidence(),
816
+ },
817
+ }
818
+
819
+
820
+ def _rehearsal_summary(result: MigrationRehearsalResult) -> dict[str, JsonValue]:
821
+ return {
822
+ "backup_id": result.backup_id,
823
+ "backup_sha256": result.backup_sha256,
824
+ "database_size_bytes": result.database_size_bytes,
825
+ "database_sha256": result.database_sha256,
826
+ "migration_outcome": result.command.outcome,
827
+ "migration_duration_seconds": result.command.duration_seconds,
828
+ "sqlite": result.sqlite.model_dump(mode="json"),
829
+ }
830
+
831
+
832
+ def _notify_stage(
833
+ observer: CandidateStageObserver | None,
834
+ stage: DeploymentState,
835
+ evidence: Mapping[str, JsonValue],
836
+ ) -> None:
837
+ if observer is not None:
838
+ observer(stage, evidence)
839
+
840
+
841
+ def _finish_failure(store: StateStore, operation_id: str, error: DployDBError) -> None:
842
+ status = (
843
+ OperationStatus.RECOVERY_REQUIRED
844
+ if error.payload.recovery_required
845
+ else OperationStatus.FAILED_SAFE
846
+ )
847
+ store.transition(
848
+ operation_id,
849
+ status=status,
850
+ stage=status.value,
851
+ message="Candidate validation did not complete.",
852
+ safety=SafetyFacts(
853
+ production_changed=error.payload.production_changed,
854
+ previous_application_running=error.payload.previous_application_running,
855
+ recovery_required=error.payload.recovery_required,
856
+ ),
857
+ failure=FailureRecord(
858
+ error_code=error.payload.error_code,
859
+ what_failed=error.payload.what_failed,
860
+ log_path=error.payload.log_path,
861
+ next_safe_action=error.payload.next_safe_action,
862
+ ),
863
+ )
864
+
865
+
866
+ def _safe_metadata_path(path: Path, loaded: LoadedConfiguration) -> Path:
867
+ safe = Path(loaded.secrets.redact_text(str(path)))
868
+ return safe if safe.is_absolute() else Path("/[REDACTED]")