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/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]")
|