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/health.py
ADDED
|
@@ -0,0 +1,625 @@
|
|
|
1
|
+
"""Bounded HTTP readiness and optional candidate smoke checks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import os
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from collections.abc import Callable, Mapping
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from enum import StrEnum
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Final, Self
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from dploydb.config import ApplicationConfig
|
|
18
|
+
from dploydb.redaction import JsonValue, SecretRegistry
|
|
19
|
+
from dploydb.runners.base import CommandExecutor, validate_release_identifier
|
|
20
|
+
from dploydb.subprocesses import CommandOutcome, CommandResult, SubprocessRunner
|
|
21
|
+
|
|
22
|
+
CANDIDATE_URL_ENV: Final = "DPLOYDB_CANDIDATE_URL"
|
|
23
|
+
APPLICATION_URL_ENV: Final = "DPLOYDB_APPLICATION_URL"
|
|
24
|
+
DPLOYDB_VERSION_ENV: Final = "DPLOYDB_VERSION"
|
|
25
|
+
DEFAULT_REQUEST_TIMEOUT_SECONDS: Final = 2.0
|
|
26
|
+
DEFAULT_RETRY_INTERVAL_SECONDS: Final = 0.1
|
|
27
|
+
DEFAULT_MAX_RESPONSE_BYTES: Final = 64 * 1024
|
|
28
|
+
SMOKE_MAX_OUTPUT_BYTES: Final = 256 * 1024
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class HealthAttemptOutcome(StrEnum):
|
|
32
|
+
"""Terminal result of one bounded readiness request."""
|
|
33
|
+
|
|
34
|
+
HEALTHY = "healthy"
|
|
35
|
+
UNHEALTHY_HTTP = "unhealthy_http"
|
|
36
|
+
REDIRECT_REFUSED = "redirect_refused"
|
|
37
|
+
TRANSPORT_ERROR = "transport_error"
|
|
38
|
+
DEADLINE_EXPIRED = "deadline_expired"
|
|
39
|
+
CANCELLED = "cancelled"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True, slots=True)
|
|
43
|
+
class BoundedResponseEvidence:
|
|
44
|
+
"""Redacted diagnostic HTTP body retained with a strict memory bound."""
|
|
45
|
+
|
|
46
|
+
text: str
|
|
47
|
+
total_bytes: int
|
|
48
|
+
retained_bytes: int
|
|
49
|
+
truncated: bool
|
|
50
|
+
|
|
51
|
+
def __post_init__(self) -> None:
|
|
52
|
+
if self.total_bytes < 0 or self.retained_bytes < 0:
|
|
53
|
+
raise ValueError("response byte counts must not be negative")
|
|
54
|
+
if self.retained_bytes > self.total_bytes:
|
|
55
|
+
raise ValueError("retained response bytes must not exceed total bytes")
|
|
56
|
+
if self.truncated != (self.retained_bytes < self.total_bytes):
|
|
57
|
+
raise ValueError("response truncation evidence is contradictory")
|
|
58
|
+
|
|
59
|
+
def as_evidence(self) -> dict[str, JsonValue]:
|
|
60
|
+
return {
|
|
61
|
+
"text": self.text,
|
|
62
|
+
"total_bytes": self.total_bytes,
|
|
63
|
+
"retained_bytes": self.retained_bytes,
|
|
64
|
+
"truncated": self.truncated,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(frozen=True, slots=True)
|
|
69
|
+
class HealthAttemptEvidence:
|
|
70
|
+
"""Safe evidence for the last readiness request made before a decision."""
|
|
71
|
+
|
|
72
|
+
attempt: int
|
|
73
|
+
outcome: HealthAttemptOutcome
|
|
74
|
+
status_code: int | None
|
|
75
|
+
body: BoundedResponseEvidence | None
|
|
76
|
+
reason: str
|
|
77
|
+
duration_seconds: float
|
|
78
|
+
|
|
79
|
+
def __post_init__(self) -> None:
|
|
80
|
+
if self.attempt <= 0:
|
|
81
|
+
raise ValueError("health attempt number must be positive")
|
|
82
|
+
if not math.isfinite(self.duration_seconds) or self.duration_seconds < 0:
|
|
83
|
+
raise ValueError("health attempt duration must be finite and non-negative")
|
|
84
|
+
if not self.reason:
|
|
85
|
+
raise ValueError("health attempt reason must not be empty")
|
|
86
|
+
|
|
87
|
+
def as_evidence(self) -> dict[str, JsonValue]:
|
|
88
|
+
return {
|
|
89
|
+
"attempt": self.attempt,
|
|
90
|
+
"outcome": self.outcome.value,
|
|
91
|
+
"status_code": self.status_code,
|
|
92
|
+
"body": None if self.body is None else self.body.as_evidence(),
|
|
93
|
+
"reason": self.reason,
|
|
94
|
+
"duration_seconds": self.duration_seconds,
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass(frozen=True, slots=True)
|
|
99
|
+
class ReadinessEvidence:
|
|
100
|
+
"""Terminal readiness decision under one fixed monotonic deadline."""
|
|
101
|
+
|
|
102
|
+
url: str
|
|
103
|
+
healthy: bool
|
|
104
|
+
attempt_count: int
|
|
105
|
+
last_attempt: HealthAttemptEvidence | None
|
|
106
|
+
duration_seconds: float
|
|
107
|
+
reason: str
|
|
108
|
+
|
|
109
|
+
def __post_init__(self) -> None:
|
|
110
|
+
if self.attempt_count < 0:
|
|
111
|
+
raise ValueError("readiness attempt count must not be negative")
|
|
112
|
+
if self.last_attempt is None and self.attempt_count != 0:
|
|
113
|
+
raise ValueError("readiness without a last attempt must have zero attempts")
|
|
114
|
+
if self.last_attempt is not None and self.last_attempt.attempt != self.attempt_count:
|
|
115
|
+
raise ValueError("last readiness attempt must match the attempt count")
|
|
116
|
+
if self.healthy and (
|
|
117
|
+
self.last_attempt is None
|
|
118
|
+
or self.last_attempt.outcome is not HealthAttemptOutcome.HEALTHY
|
|
119
|
+
):
|
|
120
|
+
raise ValueError("healthy readiness requires a healthy final attempt")
|
|
121
|
+
if not math.isfinite(self.duration_seconds) or self.duration_seconds < 0:
|
|
122
|
+
raise ValueError("readiness duration must be finite and non-negative")
|
|
123
|
+
if not self.reason:
|
|
124
|
+
raise ValueError("readiness reason must not be empty")
|
|
125
|
+
|
|
126
|
+
def as_evidence(self) -> dict[str, JsonValue]:
|
|
127
|
+
return {
|
|
128
|
+
"url": self.url,
|
|
129
|
+
"healthy": self.healthy,
|
|
130
|
+
"attempt_count": self.attempt_count,
|
|
131
|
+
"last_attempt": (
|
|
132
|
+
None if self.last_attempt is None else self.last_attempt.as_evidence()
|
|
133
|
+
),
|
|
134
|
+
"duration_seconds": self.duration_seconds,
|
|
135
|
+
"reason": self.reason,
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@dataclass(frozen=True, slots=True)
|
|
140
|
+
class CandidateHealthResult:
|
|
141
|
+
"""Passing HTTP readiness plus optional complete smoke-command evidence."""
|
|
142
|
+
|
|
143
|
+
readiness: ReadinessEvidence
|
|
144
|
+
smoke: CommandResult | None
|
|
145
|
+
|
|
146
|
+
def __post_init__(self) -> None:
|
|
147
|
+
if not self.readiness.healthy:
|
|
148
|
+
raise ValueError("candidate health result requires passing readiness")
|
|
149
|
+
if self.smoke is not None and (
|
|
150
|
+
self.smoke.outcome is not CommandOutcome.SUCCEEDED
|
|
151
|
+
or self.smoke.stdout.truncated
|
|
152
|
+
or self.smoke.stderr.truncated
|
|
153
|
+
):
|
|
154
|
+
raise ValueError("candidate health result requires complete successful smoke evidence")
|
|
155
|
+
|
|
156
|
+
def as_evidence(self) -> dict[str, JsonValue]:
|
|
157
|
+
return {
|
|
158
|
+
"readiness": self.readiness.as_evidence(),
|
|
159
|
+
"smoke": None if self.smoke is None else self.smoke.as_evidence(),
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class CandidateHealthError(RuntimeError):
|
|
164
|
+
"""Base class for a rejected candidate health check."""
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class ReadinessCheckError(CandidateHealthError):
|
|
168
|
+
"""The candidate did not return a 2xx response before the fixed deadline."""
|
|
169
|
+
|
|
170
|
+
def __init__(self, evidence: ReadinessEvidence) -> None:
|
|
171
|
+
self.evidence = evidence
|
|
172
|
+
super().__init__(evidence.reason)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class SmokeCheckError(CandidateHealthError):
|
|
176
|
+
"""Readiness passed but the optional bounded smoke command did not."""
|
|
177
|
+
|
|
178
|
+
def __init__(
|
|
179
|
+
self,
|
|
180
|
+
message: str,
|
|
181
|
+
*,
|
|
182
|
+
readiness: ReadinessEvidence,
|
|
183
|
+
command: CommandResult,
|
|
184
|
+
) -> None:
|
|
185
|
+
self.readiness = readiness
|
|
186
|
+
self.command = command
|
|
187
|
+
super().__init__(message)
|
|
188
|
+
|
|
189
|
+
@property
|
|
190
|
+
def cleanup_proven(self) -> bool:
|
|
191
|
+
return self.command.outcome is not CommandOutcome.CLEANUP_FAILED
|
|
192
|
+
|
|
193
|
+
def as_evidence(self) -> dict[str, JsonValue]:
|
|
194
|
+
return {
|
|
195
|
+
"readiness": self.readiness.as_evidence(),
|
|
196
|
+
"smoke": self.command.as_evidence(),
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class _DeadlineExpired(RuntimeError):
|
|
201
|
+
pass
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class _RequestCancelled(RuntimeError):
|
|
205
|
+
pass
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class _BoundedBodyCapture:
|
|
209
|
+
def __init__(self, limit: int) -> None:
|
|
210
|
+
self._head_limit = limit // 2
|
|
211
|
+
self._tail_limit = limit - self._head_limit
|
|
212
|
+
self._head = bytearray()
|
|
213
|
+
self._tail = bytearray()
|
|
214
|
+
self._total_bytes = 0
|
|
215
|
+
|
|
216
|
+
def append(self, data: bytes) -> None:
|
|
217
|
+
self._total_bytes += len(data)
|
|
218
|
+
head_missing = self._head_limit - len(self._head)
|
|
219
|
+
if head_missing > 0:
|
|
220
|
+
self._head.extend(data[:head_missing])
|
|
221
|
+
data = data[head_missing:]
|
|
222
|
+
if not data or self._tail_limit == 0:
|
|
223
|
+
return
|
|
224
|
+
if len(data) >= self._tail_limit:
|
|
225
|
+
self._tail[:] = data[-self._tail_limit :]
|
|
226
|
+
return
|
|
227
|
+
self._tail.extend(data)
|
|
228
|
+
overflow = len(self._tail) - self._tail_limit
|
|
229
|
+
if overflow > 0:
|
|
230
|
+
del self._tail[:overflow]
|
|
231
|
+
|
|
232
|
+
def snapshot(self, secrets: SecretRegistry) -> BoundedResponseEvidence:
|
|
233
|
+
retained = bytes(self._head + self._tail)
|
|
234
|
+
retained_bytes = len(retained)
|
|
235
|
+
return BoundedResponseEvidence(
|
|
236
|
+
text=secrets.redact_text(retained.decode("utf-8", errors="replace")),
|
|
237
|
+
total_bytes=self._total_bytes,
|
|
238
|
+
retained_bytes=retained_bytes,
|
|
239
|
+
truncated=retained_bytes < self._total_bytes,
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class ApplicationHealthChecker:
|
|
244
|
+
"""Check one configured application endpoint without trusting process state alone."""
|
|
245
|
+
|
|
246
|
+
def __init__(
|
|
247
|
+
self,
|
|
248
|
+
*,
|
|
249
|
+
application: ApplicationConfig,
|
|
250
|
+
health_url: str,
|
|
251
|
+
database_environment_name: str,
|
|
252
|
+
secrets: SecretRegistry,
|
|
253
|
+
working_directory: Path,
|
|
254
|
+
smoke_environment: Mapping[str, str] | None = None,
|
|
255
|
+
health_url_environment_name: str = APPLICATION_URL_ENV,
|
|
256
|
+
command_environment: Mapping[str, str] | None = None,
|
|
257
|
+
command_runner: CommandExecutor | None = None,
|
|
258
|
+
client: httpx.Client | None = None,
|
|
259
|
+
transport: httpx.BaseTransport | None = None,
|
|
260
|
+
request_timeout_seconds: float = DEFAULT_REQUEST_TIMEOUT_SECONDS,
|
|
261
|
+
retry_interval_seconds: float = DEFAULT_RETRY_INTERVAL_SECONDS,
|
|
262
|
+
max_response_bytes: int = DEFAULT_MAX_RESPONSE_BYTES,
|
|
263
|
+
clock: Callable[[], float] = time.monotonic,
|
|
264
|
+
sleeper: Callable[[float], None] = time.sleep,
|
|
265
|
+
) -> None:
|
|
266
|
+
if client is not None and transport is not None:
|
|
267
|
+
raise ValueError("provide either an HTTPX client or transport, not both")
|
|
268
|
+
if not database_environment_name or "=" in database_environment_name:
|
|
269
|
+
raise ValueError("database_environment_name must be a valid environment name")
|
|
270
|
+
if not health_url or not health_url_environment_name or "=" in health_url_environment_name:
|
|
271
|
+
raise ValueError("health URL and its environment name must be valid")
|
|
272
|
+
if not working_directory.is_absolute():
|
|
273
|
+
raise ValueError("working_directory must be absolute")
|
|
274
|
+
self.application = application
|
|
275
|
+
self.health_url = health_url
|
|
276
|
+
self.health_url_environment_name = health_url_environment_name
|
|
277
|
+
self.database_environment_name = database_environment_name
|
|
278
|
+
self.secrets = secrets
|
|
279
|
+
self.working_directory = working_directory
|
|
280
|
+
self.command_environment = dict(
|
|
281
|
+
os.environ if command_environment is None else command_environment
|
|
282
|
+
)
|
|
283
|
+
self.smoke_environment = dict(smoke_environment or {})
|
|
284
|
+
self.command_runner = command_runner or SubprocessRunner(
|
|
285
|
+
secrets=secrets,
|
|
286
|
+
max_output_bytes=SMOKE_MAX_OUTPUT_BYTES,
|
|
287
|
+
)
|
|
288
|
+
self.request_timeout_seconds = _positive_seconds(
|
|
289
|
+
request_timeout_seconds, "request_timeout_seconds"
|
|
290
|
+
)
|
|
291
|
+
self.retry_interval_seconds = _positive_seconds(
|
|
292
|
+
retry_interval_seconds, "retry_interval_seconds"
|
|
293
|
+
)
|
|
294
|
+
self.max_response_bytes = _positive_integer(max_response_bytes, "max_response_bytes")
|
|
295
|
+
self._clock = clock
|
|
296
|
+
self._sleeper = sleeper
|
|
297
|
+
self._owns_client = client is None
|
|
298
|
+
self._client = client or httpx.Client(
|
|
299
|
+
transport=transport,
|
|
300
|
+
follow_redirects=False,
|
|
301
|
+
trust_env=False,
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
def __enter__(self) -> Self:
|
|
305
|
+
return self
|
|
306
|
+
|
|
307
|
+
def __exit__(self, *_args: object) -> None:
|
|
308
|
+
self.close()
|
|
309
|
+
|
|
310
|
+
def close(self) -> None:
|
|
311
|
+
"""Close only the internally owned HTTPX client."""
|
|
312
|
+
if self._owns_client:
|
|
313
|
+
self._client.close()
|
|
314
|
+
|
|
315
|
+
def check_application(
|
|
316
|
+
self,
|
|
317
|
+
*,
|
|
318
|
+
version: str,
|
|
319
|
+
database_path: Path,
|
|
320
|
+
cancellation_event: threading.Event | None = None,
|
|
321
|
+
) -> CandidateHealthResult:
|
|
322
|
+
"""Require readiness before the optional smoke command can execute."""
|
|
323
|
+
release = validate_release_identifier(version)
|
|
324
|
+
selected_database = _absolute_existing_file(database_path)
|
|
325
|
+
readiness = self.wait_until_ready(cancellation_event=cancellation_event)
|
|
326
|
+
command = self.application.smoke_command
|
|
327
|
+
if command is None:
|
|
328
|
+
return CandidateHealthResult(readiness=readiness, smoke=None)
|
|
329
|
+
|
|
330
|
+
environment = dict(self.command_environment)
|
|
331
|
+
environment.update(self.smoke_environment)
|
|
332
|
+
environment[self.database_environment_name] = str(selected_database)
|
|
333
|
+
environment[DPLOYDB_VERSION_ENV] = release
|
|
334
|
+
environment[self.health_url_environment_name] = self.health_url
|
|
335
|
+
smoke = self.command_runner.run(
|
|
336
|
+
command,
|
|
337
|
+
timeout_seconds=self.application.startup_timeout_seconds,
|
|
338
|
+
environment=environment,
|
|
339
|
+
working_directory=self.working_directory,
|
|
340
|
+
cancellation_event=cancellation_event,
|
|
341
|
+
)
|
|
342
|
+
failure = _smoke_failure(smoke)
|
|
343
|
+
if failure is not None:
|
|
344
|
+
raise SmokeCheckError(failure, readiness=readiness, command=smoke)
|
|
345
|
+
return CandidateHealthResult(readiness=readiness, smoke=smoke)
|
|
346
|
+
|
|
347
|
+
def wait_until_ready(
|
|
348
|
+
self,
|
|
349
|
+
*,
|
|
350
|
+
cancellation_event: threading.Event | None = None,
|
|
351
|
+
) -> ReadinessEvidence:
|
|
352
|
+
"""Poll the configured URL under one monotonic overall deadline."""
|
|
353
|
+
started_at = self._clock()
|
|
354
|
+
deadline = started_at + float(self.application.startup_timeout_seconds)
|
|
355
|
+
safe_url = self.secrets.redact_text(self.health_url)
|
|
356
|
+
attempts = 0
|
|
357
|
+
last_attempt: HealthAttemptEvidence | None = None
|
|
358
|
+
|
|
359
|
+
while True:
|
|
360
|
+
if cancellation_event is not None and cancellation_event.is_set():
|
|
361
|
+
evidence = ReadinessEvidence(
|
|
362
|
+
url=safe_url,
|
|
363
|
+
healthy=False,
|
|
364
|
+
attempt_count=attempts,
|
|
365
|
+
last_attempt=last_attempt,
|
|
366
|
+
duration_seconds=self._duration(started_at),
|
|
367
|
+
reason="candidate readiness was cancelled before a passing response",
|
|
368
|
+
)
|
|
369
|
+
raise ReadinessCheckError(evidence)
|
|
370
|
+
remaining = deadline - self._clock()
|
|
371
|
+
if remaining <= 0:
|
|
372
|
+
raise ReadinessCheckError(
|
|
373
|
+
self._failed_readiness(
|
|
374
|
+
safe_url=safe_url,
|
|
375
|
+
attempts=attempts,
|
|
376
|
+
last_attempt=last_attempt,
|
|
377
|
+
started_at=started_at,
|
|
378
|
+
)
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
attempts += 1
|
|
382
|
+
last_attempt = self._request_once(
|
|
383
|
+
attempt=attempts,
|
|
384
|
+
deadline=deadline,
|
|
385
|
+
cancellation_event=cancellation_event,
|
|
386
|
+
)
|
|
387
|
+
if last_attempt.outcome is HealthAttemptOutcome.HEALTHY:
|
|
388
|
+
return ReadinessEvidence(
|
|
389
|
+
url=safe_url,
|
|
390
|
+
healthy=True,
|
|
391
|
+
attempt_count=attempts,
|
|
392
|
+
last_attempt=last_attempt,
|
|
393
|
+
duration_seconds=self._duration(started_at),
|
|
394
|
+
reason="candidate returned a real HTTP 2xx readiness response",
|
|
395
|
+
)
|
|
396
|
+
if last_attempt.outcome is HealthAttemptOutcome.CANCELLED:
|
|
397
|
+
raise ReadinessCheckError(
|
|
398
|
+
ReadinessEvidence(
|
|
399
|
+
url=safe_url,
|
|
400
|
+
healthy=False,
|
|
401
|
+
attempt_count=attempts,
|
|
402
|
+
last_attempt=last_attempt,
|
|
403
|
+
duration_seconds=self._duration(started_at),
|
|
404
|
+
reason="candidate readiness was cancelled before a passing response",
|
|
405
|
+
)
|
|
406
|
+
)
|
|
407
|
+
|
|
408
|
+
remaining = deadline - self._clock()
|
|
409
|
+
if remaining <= 0:
|
|
410
|
+
continue
|
|
411
|
+
self._sleeper(min(self.retry_interval_seconds, remaining))
|
|
412
|
+
|
|
413
|
+
def _request_once(
|
|
414
|
+
self,
|
|
415
|
+
*,
|
|
416
|
+
attempt: int,
|
|
417
|
+
deadline: float,
|
|
418
|
+
cancellation_event: threading.Event | None,
|
|
419
|
+
) -> HealthAttemptEvidence:
|
|
420
|
+
started_at = self._clock()
|
|
421
|
+
status_code: int | None = None
|
|
422
|
+
body: BoundedResponseEvidence | None = None
|
|
423
|
+
try:
|
|
424
|
+
remaining = deadline - started_at
|
|
425
|
+
if remaining <= 0:
|
|
426
|
+
raise _DeadlineExpired
|
|
427
|
+
timeout = min(self.request_timeout_seconds, remaining)
|
|
428
|
+
capture = _BoundedBodyCapture(self.max_response_bytes)
|
|
429
|
+
with self._client.stream(
|
|
430
|
+
"GET",
|
|
431
|
+
self.health_url,
|
|
432
|
+
follow_redirects=False,
|
|
433
|
+
timeout=httpx.Timeout(timeout),
|
|
434
|
+
) as response:
|
|
435
|
+
status_code = response.status_code
|
|
436
|
+
for chunk in response.iter_bytes(chunk_size=8192):
|
|
437
|
+
if cancellation_event is not None and cancellation_event.is_set():
|
|
438
|
+
raise _RequestCancelled
|
|
439
|
+
if self._clock() >= deadline:
|
|
440
|
+
raise _DeadlineExpired
|
|
441
|
+
capture.append(chunk)
|
|
442
|
+
body = capture.snapshot(self.secrets)
|
|
443
|
+
except _RequestCancelled:
|
|
444
|
+
return self._attempt(
|
|
445
|
+
attempt,
|
|
446
|
+
HealthAttemptOutcome.CANCELLED,
|
|
447
|
+
status_code,
|
|
448
|
+
body,
|
|
449
|
+
"readiness request was cancelled",
|
|
450
|
+
started_at,
|
|
451
|
+
)
|
|
452
|
+
except _DeadlineExpired:
|
|
453
|
+
return self._attempt(
|
|
454
|
+
attempt,
|
|
455
|
+
HealthAttemptOutcome.DEADLINE_EXPIRED,
|
|
456
|
+
status_code,
|
|
457
|
+
body,
|
|
458
|
+
"readiness request reached the overall startup deadline",
|
|
459
|
+
started_at,
|
|
460
|
+
)
|
|
461
|
+
except httpx.TransportError as error:
|
|
462
|
+
return self._attempt(
|
|
463
|
+
attempt,
|
|
464
|
+
HealthAttemptOutcome.TRANSPORT_ERROR,
|
|
465
|
+
None,
|
|
466
|
+
None,
|
|
467
|
+
"readiness request failed: " + self.secrets.redact_text(str(error)),
|
|
468
|
+
started_at,
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
assert status_code is not None
|
|
472
|
+
if 200 <= status_code < 300:
|
|
473
|
+
outcome = HealthAttemptOutcome.HEALTHY
|
|
474
|
+
reason = f"HTTP {status_code}"
|
|
475
|
+
elif 300 <= status_code < 400:
|
|
476
|
+
outcome = HealthAttemptOutcome.REDIRECT_REFUSED
|
|
477
|
+
reason = f"HTTP {status_code} redirect was refused"
|
|
478
|
+
else:
|
|
479
|
+
outcome = HealthAttemptOutcome.UNHEALTHY_HTTP
|
|
480
|
+
reason = f"HTTP {status_code} was not healthy"
|
|
481
|
+
return self._attempt(attempt, outcome, status_code, body, reason, started_at)
|
|
482
|
+
|
|
483
|
+
def _attempt(
|
|
484
|
+
self,
|
|
485
|
+
attempt: int,
|
|
486
|
+
outcome: HealthAttemptOutcome,
|
|
487
|
+
status_code: int | None,
|
|
488
|
+
body: BoundedResponseEvidence | None,
|
|
489
|
+
reason: str,
|
|
490
|
+
started_at: float,
|
|
491
|
+
) -> HealthAttemptEvidence:
|
|
492
|
+
return HealthAttemptEvidence(
|
|
493
|
+
attempt=attempt,
|
|
494
|
+
outcome=outcome,
|
|
495
|
+
status_code=status_code,
|
|
496
|
+
body=body,
|
|
497
|
+
reason=self.secrets.redact_text(reason),
|
|
498
|
+
duration_seconds=self._duration(started_at),
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
def _failed_readiness(
|
|
502
|
+
self,
|
|
503
|
+
*,
|
|
504
|
+
safe_url: str,
|
|
505
|
+
attempts: int,
|
|
506
|
+
last_attempt: HealthAttemptEvidence | None,
|
|
507
|
+
started_at: float,
|
|
508
|
+
) -> ReadinessEvidence:
|
|
509
|
+
last_reason = "no request completed" if last_attempt is None else last_attempt.reason
|
|
510
|
+
return ReadinessEvidence(
|
|
511
|
+
url=safe_url,
|
|
512
|
+
healthy=False,
|
|
513
|
+
attempt_count=attempts,
|
|
514
|
+
last_attempt=last_attempt,
|
|
515
|
+
duration_seconds=self._duration(started_at),
|
|
516
|
+
reason=(
|
|
517
|
+
"candidate readiness deadline expired after "
|
|
518
|
+
f"{self.application.startup_timeout_seconds:g} seconds; "
|
|
519
|
+
f"last result: {last_reason}"
|
|
520
|
+
),
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
def _duration(self, started_at: float) -> float:
|
|
524
|
+
return max(0.0, self._clock() - started_at)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
class CandidateHealthChecker(ApplicationHealthChecker):
|
|
528
|
+
"""Backward-compatible candidate health adapter over the generic boundary."""
|
|
529
|
+
|
|
530
|
+
def __init__(
|
|
531
|
+
self,
|
|
532
|
+
*,
|
|
533
|
+
application: ApplicationConfig,
|
|
534
|
+
database_environment_name: str,
|
|
535
|
+
secrets: SecretRegistry,
|
|
536
|
+
working_directory: Path,
|
|
537
|
+
command_environment: Mapping[str, str] | None = None,
|
|
538
|
+
command_runner: CommandExecutor | None = None,
|
|
539
|
+
client: httpx.Client | None = None,
|
|
540
|
+
transport: httpx.BaseTransport | None = None,
|
|
541
|
+
request_timeout_seconds: float = DEFAULT_REQUEST_TIMEOUT_SECONDS,
|
|
542
|
+
retry_interval_seconds: float = DEFAULT_RETRY_INTERVAL_SECONDS,
|
|
543
|
+
max_response_bytes: int = DEFAULT_MAX_RESPONSE_BYTES,
|
|
544
|
+
clock: Callable[[], float] = time.monotonic,
|
|
545
|
+
sleeper: Callable[[float], None] = time.sleep,
|
|
546
|
+
) -> None:
|
|
547
|
+
super().__init__(
|
|
548
|
+
application=application,
|
|
549
|
+
health_url=application.candidate_health_url,
|
|
550
|
+
database_environment_name=database_environment_name,
|
|
551
|
+
secrets=secrets,
|
|
552
|
+
working_directory=working_directory,
|
|
553
|
+
smoke_environment=application.test_mode_env,
|
|
554
|
+
health_url_environment_name=CANDIDATE_URL_ENV,
|
|
555
|
+
command_environment=command_environment,
|
|
556
|
+
command_runner=command_runner,
|
|
557
|
+
client=client,
|
|
558
|
+
transport=transport,
|
|
559
|
+
request_timeout_seconds=request_timeout_seconds,
|
|
560
|
+
retry_interval_seconds=retry_interval_seconds,
|
|
561
|
+
max_response_bytes=max_response_bytes,
|
|
562
|
+
clock=clock,
|
|
563
|
+
sleeper=sleeper,
|
|
564
|
+
)
|
|
565
|
+
|
|
566
|
+
def check(
|
|
567
|
+
self,
|
|
568
|
+
*,
|
|
569
|
+
version: str,
|
|
570
|
+
rehearsal_database_path: Path,
|
|
571
|
+
cancellation_event: threading.Event | None = None,
|
|
572
|
+
) -> CandidateHealthResult:
|
|
573
|
+
return self.check_application(
|
|
574
|
+
version=version,
|
|
575
|
+
database_path=rehearsal_database_path,
|
|
576
|
+
cancellation_event=cancellation_event,
|
|
577
|
+
)
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _smoke_failure(result: CommandResult) -> str | None:
|
|
581
|
+
if result.stdout.truncated or result.stderr.truncated:
|
|
582
|
+
return "candidate smoke output exceeded the complete-capture safety bound"
|
|
583
|
+
if result.outcome is CommandOutcome.SUCCEEDED:
|
|
584
|
+
return None
|
|
585
|
+
if result.outcome is CommandOutcome.NONZERO_EXIT:
|
|
586
|
+
return f"candidate smoke command exited with status {result.exit_code}"
|
|
587
|
+
if result.outcome is CommandOutcome.TIMED_OUT:
|
|
588
|
+
return "candidate smoke command timed out and its process group was terminated"
|
|
589
|
+
if result.outcome is CommandOutcome.CANCELLED:
|
|
590
|
+
return "candidate smoke command was cancelled and its process group was terminated"
|
|
591
|
+
if result.outcome is CommandOutcome.CLEANUP_FAILED:
|
|
592
|
+
return "candidate smoke process cleanup could not be proven: " + (
|
|
593
|
+
result.cleanup_error or "unknown cleanup failure"
|
|
594
|
+
)
|
|
595
|
+
return "candidate smoke command could not start: " + (
|
|
596
|
+
result.start_error or "unknown start failure"
|
|
597
|
+
)
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
def _absolute_existing_file(path: Path) -> Path:
|
|
601
|
+
if not isinstance(path, Path):
|
|
602
|
+
raise TypeError("rehearsal_database_path must be a pathlib.Path")
|
|
603
|
+
if not path.is_absolute():
|
|
604
|
+
raise ValueError("rehearsal_database_path must be absolute")
|
|
605
|
+
resolved = path.resolve()
|
|
606
|
+
if not resolved.is_file():
|
|
607
|
+
raise ValueError("rehearsal_database_path must identify an existing file")
|
|
608
|
+
return resolved
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def _positive_seconds(value: float, name: str) -> float:
|
|
612
|
+
if isinstance(value, bool) or not isinstance(value, int | float):
|
|
613
|
+
raise TypeError(f"{name} must be a number")
|
|
614
|
+
result = float(value)
|
|
615
|
+
if not math.isfinite(result) or result <= 0:
|
|
616
|
+
raise ValueError(f"{name} must be finite and greater than zero")
|
|
617
|
+
return result
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def _positive_integer(value: int, name: str) -> int:
|
|
621
|
+
if isinstance(value, bool) or not isinstance(value, int):
|
|
622
|
+
raise TypeError(f"{name} must be an integer")
|
|
623
|
+
if value <= 0:
|
|
624
|
+
raise ValueError(f"{name} must be greater than zero")
|
|
625
|
+
return value
|