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/diagnostics.py
ADDED
|
@@ -0,0 +1,1020 @@
|
|
|
1
|
+
"""Read-only runtime status and bounded host diagnostics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import math
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import socket
|
|
9
|
+
import stat
|
|
10
|
+
from collections.abc import Mapping, Sequence
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Final
|
|
14
|
+
from urllib.parse import urlsplit
|
|
15
|
+
from uuid import uuid4
|
|
16
|
+
|
|
17
|
+
from dploydb.config import DployDBConfig, LoadedConfiguration
|
|
18
|
+
from dploydb.errors import (
|
|
19
|
+
DployDBError,
|
|
20
|
+
ExternalCommandError,
|
|
21
|
+
LockUnavailableError,
|
|
22
|
+
RecoveryRequiredError,
|
|
23
|
+
SafetyCheckError,
|
|
24
|
+
)
|
|
25
|
+
from dploydb.locking import LockInspection, LockInspectionState, inspect_lock
|
|
26
|
+
from dploydb.models import (
|
|
27
|
+
DiagnosticCheck,
|
|
28
|
+
DiagnosticOutcome,
|
|
29
|
+
FailurePayload,
|
|
30
|
+
LockOwnerState,
|
|
31
|
+
OperationManifest,
|
|
32
|
+
OperationStatus,
|
|
33
|
+
RuntimeStatus,
|
|
34
|
+
serialize_utc_timestamp,
|
|
35
|
+
utc_now,
|
|
36
|
+
)
|
|
37
|
+
from dploydb.redaction import JsonValue, SecretRegistry
|
|
38
|
+
from dploydb.sqlite_checks import verify_sqlite_database
|
|
39
|
+
from dploydb.state import DIRECTORY_MODE, StateStore
|
|
40
|
+
from dploydb.storage.local import DIRECTORY_MODE as BACKUP_DIRECTORY_MODE
|
|
41
|
+
from dploydb.storage.s3 import configured_s3_storage
|
|
42
|
+
from dploydb.subprocesses import CommandResult, SubprocessRunner
|
|
43
|
+
|
|
44
|
+
DOCTOR_COMMAND_TIMEOUT_SECONDS: Final[float] = 10.0
|
|
45
|
+
_DEFERRED_CHECKS: Final[tuple[tuple[str, str], ...]] = (
|
|
46
|
+
(
|
|
47
|
+
"migration_execution",
|
|
48
|
+
"Doctor never executes migrations; the lock-tracked rehearsal stage owns this check.",
|
|
49
|
+
),
|
|
50
|
+
(
|
|
51
|
+
"application_health",
|
|
52
|
+
"Doctor does not contact production health; deploy and recovery own this check.",
|
|
53
|
+
),
|
|
54
|
+
(
|
|
55
|
+
"traffic_execution",
|
|
56
|
+
"Doctor never executes traffic hooks; deploy, restore, and recovery own them.",
|
|
57
|
+
),
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True, slots=True)
|
|
62
|
+
class StatusReport:
|
|
63
|
+
"""Stable read-only status report for terminal and JSON rendering."""
|
|
64
|
+
|
|
65
|
+
project: str
|
|
66
|
+
checked_at: str
|
|
67
|
+
status: RuntimeStatus
|
|
68
|
+
lock: dict[str, JsonValue]
|
|
69
|
+
operation: dict[str, JsonValue] | None
|
|
70
|
+
warnings: tuple[str, ...]
|
|
71
|
+
next_safe_action: str
|
|
72
|
+
failure: FailurePayload | None = None
|
|
73
|
+
|
|
74
|
+
@property
|
|
75
|
+
def exit_code(self) -> int:
|
|
76
|
+
return 0 if self.failure is None else self.failure.exit_code
|
|
77
|
+
|
|
78
|
+
def as_dict(self) -> dict[str, Any]:
|
|
79
|
+
value: dict[str, Any] = {
|
|
80
|
+
"ok": self.failure is None,
|
|
81
|
+
"command": "status",
|
|
82
|
+
"project": self.project,
|
|
83
|
+
"checked_at": self.checked_at,
|
|
84
|
+
"status": self.status.value,
|
|
85
|
+
"recovery_required": self.failure is not None,
|
|
86
|
+
"lock": self.lock,
|
|
87
|
+
"operation": self.operation,
|
|
88
|
+
"warnings": list(self.warnings),
|
|
89
|
+
"next_safe_action": self.next_safe_action,
|
|
90
|
+
}
|
|
91
|
+
if self.failure is not None:
|
|
92
|
+
value.update(dict(self.failure.as_dict()))
|
|
93
|
+
value["command"] = "status"
|
|
94
|
+
value["project"] = self.project
|
|
95
|
+
value["checked_at"] = self.checked_at
|
|
96
|
+
value["status"] = self.status.value
|
|
97
|
+
value["lock"] = self.lock
|
|
98
|
+
value["operation"] = self.operation
|
|
99
|
+
value["warnings"] = list(self.warnings)
|
|
100
|
+
return value
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass(frozen=True, slots=True)
|
|
104
|
+
class DoctorReport:
|
|
105
|
+
"""Stable aggregate of one normal or deep doctor pass."""
|
|
106
|
+
|
|
107
|
+
project: str
|
|
108
|
+
checked_at: str
|
|
109
|
+
deep: bool
|
|
110
|
+
checks: tuple[DiagnosticCheck, ...]
|
|
111
|
+
failure: FailurePayload | None = None
|
|
112
|
+
|
|
113
|
+
@property
|
|
114
|
+
def exit_code(self) -> int:
|
|
115
|
+
return 0 if self.failure is None else self.failure.exit_code
|
|
116
|
+
|
|
117
|
+
def as_dict(self) -> dict[str, Any]:
|
|
118
|
+
counts = {
|
|
119
|
+
outcome.value: sum(check.outcome is outcome for check in self.checks)
|
|
120
|
+
for outcome in DiagnosticOutcome
|
|
121
|
+
}
|
|
122
|
+
value: dict[str, Any] = {
|
|
123
|
+
"ok": self.failure is None,
|
|
124
|
+
"command": "doctor",
|
|
125
|
+
"project": self.project,
|
|
126
|
+
"checked_at": self.checked_at,
|
|
127
|
+
"deep": self.deep,
|
|
128
|
+
"summary": counts,
|
|
129
|
+
"checks": [check.model_dump(mode="json") for check in self.checks],
|
|
130
|
+
}
|
|
131
|
+
if self.failure is not None:
|
|
132
|
+
value.update(dict(self.failure.as_dict()))
|
|
133
|
+
value["command"] = "doctor"
|
|
134
|
+
value["project"] = self.project
|
|
135
|
+
value["checked_at"] = self.checked_at
|
|
136
|
+
value["deep"] = self.deep
|
|
137
|
+
value["summary"] = counts
|
|
138
|
+
value["checks"] = [check.model_dump(mode="json") for check in self.checks]
|
|
139
|
+
return value
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def inspect_runtime_status(
|
|
143
|
+
config: DployDBConfig,
|
|
144
|
+
*,
|
|
145
|
+
secrets: SecretRegistry,
|
|
146
|
+
) -> StatusReport:
|
|
147
|
+
"""Reconcile kernel-lock truth and durable operation evidence without writes."""
|
|
148
|
+
checked_at = serialize_utc_timestamp(utc_now())
|
|
149
|
+
lock = inspect_lock(config.state_directory, secrets=secrets)
|
|
150
|
+
safe_lock = _lock_evidence(lock, secrets)
|
|
151
|
+
store = StateStore(config.state_directory, secrets=secrets)
|
|
152
|
+
|
|
153
|
+
if lock.state is LockInspectionState.ACTIVE:
|
|
154
|
+
warnings: list[str] = []
|
|
155
|
+
operation: OperationManifest | None = None
|
|
156
|
+
if lock.metadata_error is not None:
|
|
157
|
+
warnings.append(secrets.redact_text(lock.metadata_error))
|
|
158
|
+
if lock.owner is None or lock.owner.state is not LockOwnerState.ACTIVE:
|
|
159
|
+
warnings.append(
|
|
160
|
+
"The kernel lock is held while current owner details are unavailable; "
|
|
161
|
+
"the holder may be between durable updates."
|
|
162
|
+
)
|
|
163
|
+
else:
|
|
164
|
+
try:
|
|
165
|
+
operation = store.read_operation(lock.owner.operation_id)[0]
|
|
166
|
+
except DployDBError:
|
|
167
|
+
warnings.append(
|
|
168
|
+
"Operation details changed or were unreadable while the kernel lock was "
|
|
169
|
+
"held; wait for the active operation before diagnosing recovery."
|
|
170
|
+
)
|
|
171
|
+
return StatusReport(
|
|
172
|
+
project=config.project,
|
|
173
|
+
checked_at=checked_at,
|
|
174
|
+
status=RuntimeStatus.ACTIVE,
|
|
175
|
+
lock=safe_lock,
|
|
176
|
+
operation=_operation_evidence(operation, secrets),
|
|
177
|
+
warnings=tuple(warnings),
|
|
178
|
+
next_safe_action="Wait for the active operation to finish, then run status again.",
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
if lock.state is LockInspectionState.RECOVERY_REQUIRED:
|
|
182
|
+
return _recovery_status(
|
|
183
|
+
config=config,
|
|
184
|
+
checked_at=checked_at,
|
|
185
|
+
lock=safe_lock,
|
|
186
|
+
operation=None,
|
|
187
|
+
secrets=secrets,
|
|
188
|
+
what_failed=lock.metadata_error or "Deployment lock evidence is unsafe.",
|
|
189
|
+
log_path=lock.owner_path,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
try:
|
|
193
|
+
latest = store.latest_operation()
|
|
194
|
+
except DployDBError as error:
|
|
195
|
+
return _recovery_status(
|
|
196
|
+
config=config,
|
|
197
|
+
checked_at=checked_at,
|
|
198
|
+
lock=safe_lock,
|
|
199
|
+
operation=None,
|
|
200
|
+
secrets=secrets,
|
|
201
|
+
what_failed=error.payload.what_failed,
|
|
202
|
+
log_path=error.payload.log_path or config.state_directory,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
operation_evidence = _operation_evidence(latest, secrets)
|
|
206
|
+
if lock.state is LockInspectionState.STALE_OWNER:
|
|
207
|
+
matching_unfinished = (
|
|
208
|
+
lock.owner is not None
|
|
209
|
+
and latest is not None
|
|
210
|
+
and lock.owner.operation_id == latest.operation_id
|
|
211
|
+
and latest.status is OperationStatus.IN_PROGRESS
|
|
212
|
+
)
|
|
213
|
+
if matching_unfinished:
|
|
214
|
+
assert latest is not None
|
|
215
|
+
return _interrupted_status(
|
|
216
|
+
config=config,
|
|
217
|
+
checked_at=checked_at,
|
|
218
|
+
lock=safe_lock,
|
|
219
|
+
operation=latest,
|
|
220
|
+
secrets=secrets,
|
|
221
|
+
detail="The lock owner is stale and its operation did not finish.",
|
|
222
|
+
)
|
|
223
|
+
return _recovery_status(
|
|
224
|
+
config=config,
|
|
225
|
+
checked_at=checked_at,
|
|
226
|
+
lock=safe_lock,
|
|
227
|
+
operation=latest,
|
|
228
|
+
secrets=secrets,
|
|
229
|
+
what_failed="Stale lock-owner metadata contradicts durable operation state.",
|
|
230
|
+
log_path=lock.owner_path,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
if latest is not None and latest.status is OperationStatus.IN_PROGRESS:
|
|
234
|
+
return _interrupted_status(
|
|
235
|
+
config=config,
|
|
236
|
+
checked_at=checked_at,
|
|
237
|
+
lock=safe_lock,
|
|
238
|
+
operation=latest,
|
|
239
|
+
secrets=secrets,
|
|
240
|
+
detail="An operation is unfinished but no process holds the deployment lock.",
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
if latest is not None and latest.status is OperationStatus.RECOVERY_REQUIRED:
|
|
244
|
+
return _recovery_status(
|
|
245
|
+
config=config,
|
|
246
|
+
checked_at=checked_at,
|
|
247
|
+
lock=safe_lock,
|
|
248
|
+
operation=latest,
|
|
249
|
+
secrets=secrets,
|
|
250
|
+
what_failed=(
|
|
251
|
+
latest.failure.what_failed
|
|
252
|
+
if latest.failure is not None
|
|
253
|
+
else "The latest operation requires recovery."
|
|
254
|
+
),
|
|
255
|
+
log_path=(latest.failure.log_path if latest.failure is not None else None),
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
return StatusReport(
|
|
259
|
+
project=config.project,
|
|
260
|
+
checked_at=checked_at,
|
|
261
|
+
status=RuntimeStatus.IDLE,
|
|
262
|
+
lock=safe_lock,
|
|
263
|
+
operation=operation_evidence,
|
|
264
|
+
warnings=(),
|
|
265
|
+
next_safe_action="The deployment safety state is idle.",
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def run_doctor(
|
|
270
|
+
loaded: LoadedConfiguration,
|
|
271
|
+
*,
|
|
272
|
+
config_path: Path,
|
|
273
|
+
deep: bool,
|
|
274
|
+
environment: Mapping[str, str] | None = None,
|
|
275
|
+
runner: SubprocessRunner | None = None,
|
|
276
|
+
) -> DoctorReport:
|
|
277
|
+
"""Run bounded host and SQLite checks without executing production behavior."""
|
|
278
|
+
config = loaded.config
|
|
279
|
+
secrets = loaded.secrets
|
|
280
|
+
checked_at = serialize_utc_timestamp(utc_now())
|
|
281
|
+
checks: list[DiagnosticCheck] = []
|
|
282
|
+
command_environment = dict(os.environ if environment is None else environment)
|
|
283
|
+
command_runner = runner or SubprocessRunner(secrets=secrets)
|
|
284
|
+
config_directory = config_path.absolute().parent
|
|
285
|
+
|
|
286
|
+
status = inspect_runtime_status(config, secrets=secrets)
|
|
287
|
+
if status.status is RuntimeStatus.ACTIVE:
|
|
288
|
+
checks.append(
|
|
289
|
+
_check(
|
|
290
|
+
secrets,
|
|
291
|
+
"runtime_state",
|
|
292
|
+
DiagnosticOutcome.FAILED,
|
|
293
|
+
"Another DployDB operation holds the deployment lock.",
|
|
294
|
+
{"status": status.status.value},
|
|
295
|
+
)
|
|
296
|
+
)
|
|
297
|
+
lock_failure = LockUnavailableError(
|
|
298
|
+
"Another DployDB operation holds the deployment lock.",
|
|
299
|
+
production_changed=False,
|
|
300
|
+
previous_application_running=None,
|
|
301
|
+
log_path=str(config.state_directory / "deployment-lock-owner.json"),
|
|
302
|
+
next_safe_action="Wait for the active operation to finish, then run doctor again.",
|
|
303
|
+
).payload
|
|
304
|
+
return _finish_doctor(config, checked_at, deep, checks, lock_failure, secrets)
|
|
305
|
+
if status.failure is not None:
|
|
306
|
+
checks.append(
|
|
307
|
+
_check(
|
|
308
|
+
secrets,
|
|
309
|
+
"runtime_state",
|
|
310
|
+
DiagnosticOutcome.FAILED,
|
|
311
|
+
status.failure.what_failed,
|
|
312
|
+
{"status": status.status.value},
|
|
313
|
+
)
|
|
314
|
+
)
|
|
315
|
+
return _finish_doctor(config, checked_at, deep, checks, status.failure, secrets)
|
|
316
|
+
checks.append(
|
|
317
|
+
_check(
|
|
318
|
+
secrets,
|
|
319
|
+
"runtime_state",
|
|
320
|
+
DiagnosticOutcome.PASSED,
|
|
321
|
+
"No active lock or unresolved operation blocks diagnostics.",
|
|
322
|
+
)
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
database_readable = _check_regular_readable(
|
|
326
|
+
checks, secrets, "database_file", config.database.path
|
|
327
|
+
)
|
|
328
|
+
if database_readable:
|
|
329
|
+
try:
|
|
330
|
+
sqlite_evidence = verify_sqlite_database(config.database.path, deep=deep)
|
|
331
|
+
except DployDBError as error:
|
|
332
|
+
checks.append(
|
|
333
|
+
_check(
|
|
334
|
+
secrets,
|
|
335
|
+
"sqlite_integrity",
|
|
336
|
+
DiagnosticOutcome.FAILED,
|
|
337
|
+
error.payload.what_failed,
|
|
338
|
+
{"path": str(config.database.path), "deep": deep},
|
|
339
|
+
)
|
|
340
|
+
)
|
|
341
|
+
else:
|
|
342
|
+
checks.append(
|
|
343
|
+
_check(
|
|
344
|
+
secrets,
|
|
345
|
+
"sqlite_integrity",
|
|
346
|
+
DiagnosticOutcome.PASSED,
|
|
347
|
+
"SQLite integrity checks passed.",
|
|
348
|
+
sqlite_evidence.model_dump(mode="json"),
|
|
349
|
+
)
|
|
350
|
+
)
|
|
351
|
+
else:
|
|
352
|
+
checks.append(
|
|
353
|
+
_check(
|
|
354
|
+
secrets,
|
|
355
|
+
"sqlite_integrity",
|
|
356
|
+
DiagnosticOutcome.SKIPPED,
|
|
357
|
+
"SQLite checks were skipped because the database file check failed.",
|
|
358
|
+
{"path": str(config.database.path), "deep": deep},
|
|
359
|
+
)
|
|
360
|
+
)
|
|
361
|
+
_check_regular_readable(checks, secrets, "compose_file", config.application.compose_file)
|
|
362
|
+
_check_directory_destination(
|
|
363
|
+
checks,
|
|
364
|
+
secrets,
|
|
365
|
+
"database_directory",
|
|
366
|
+
config.database.path.parent,
|
|
367
|
+
required_mode=None,
|
|
368
|
+
)
|
|
369
|
+
_check_directory_destination(
|
|
370
|
+
checks,
|
|
371
|
+
secrets,
|
|
372
|
+
"state_directory",
|
|
373
|
+
config.state_directory,
|
|
374
|
+
required_mode=DIRECTORY_MODE,
|
|
375
|
+
)
|
|
376
|
+
_check_directory_destination(
|
|
377
|
+
checks,
|
|
378
|
+
secrets,
|
|
379
|
+
"backup_directory",
|
|
380
|
+
config.backup.local_directory,
|
|
381
|
+
required_mode=BACKUP_DIRECTORY_MODE,
|
|
382
|
+
)
|
|
383
|
+
remote_failed = _check_remote_storage(
|
|
384
|
+
checks,
|
|
385
|
+
loaded,
|
|
386
|
+
deep=deep,
|
|
387
|
+
environment=command_environment,
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
configured_commands: tuple[tuple[str, Sequence[str]], ...] = (
|
|
391
|
+
("migration_executable", config.migration.command),
|
|
392
|
+
*(
|
|
393
|
+
(("smoke_executable", config.application.smoke_command),)
|
|
394
|
+
if config.application.smoke_command is not None
|
|
395
|
+
else ()
|
|
396
|
+
),
|
|
397
|
+
("maintenance_on_executable", config.traffic.maintenance_on_command),
|
|
398
|
+
("maintenance_off_executable", config.traffic.maintenance_off_command),
|
|
399
|
+
("activate_new_executable", config.traffic.activate_new_command),
|
|
400
|
+
("activate_old_executable", config.traffic.activate_old_command),
|
|
401
|
+
)
|
|
402
|
+
for check_id, command in configured_commands:
|
|
403
|
+
resolved = _resolve_executable(command[0], config_directory, command_environment)
|
|
404
|
+
if resolved is None:
|
|
405
|
+
checks.append(
|
|
406
|
+
_check(
|
|
407
|
+
secrets,
|
|
408
|
+
check_id,
|
|
409
|
+
DiagnosticOutcome.FAILED,
|
|
410
|
+
f"Configured executable could not be resolved: {command[0]}",
|
|
411
|
+
)
|
|
412
|
+
)
|
|
413
|
+
else:
|
|
414
|
+
checks.append(
|
|
415
|
+
_check(
|
|
416
|
+
secrets,
|
|
417
|
+
check_id,
|
|
418
|
+
DiagnosticOutcome.PASSED,
|
|
419
|
+
"Configured executable is available.",
|
|
420
|
+
{"path": str(resolved)},
|
|
421
|
+
)
|
|
422
|
+
)
|
|
423
|
+
|
|
424
|
+
docker = _resolve_executable("docker", config_directory, command_environment)
|
|
425
|
+
external_failed = remote_failed
|
|
426
|
+
if docker is None:
|
|
427
|
+
checks.append(
|
|
428
|
+
_check(
|
|
429
|
+
secrets,
|
|
430
|
+
"docker_cli",
|
|
431
|
+
DiagnosticOutcome.FAILED,
|
|
432
|
+
"Docker executable could not be resolved from PATH.",
|
|
433
|
+
)
|
|
434
|
+
)
|
|
435
|
+
else:
|
|
436
|
+
for check_id, command in (
|
|
437
|
+
("docker_cli", (str(docker), "--version")),
|
|
438
|
+
("docker_compose", (str(docker), "compose", "version")),
|
|
439
|
+
):
|
|
440
|
+
result = command_runner.run(
|
|
441
|
+
command,
|
|
442
|
+
timeout_seconds=DOCTOR_COMMAND_TIMEOUT_SECONDS,
|
|
443
|
+
environment=command_environment,
|
|
444
|
+
working_directory=config_directory,
|
|
445
|
+
)
|
|
446
|
+
external_failed |= not result.succeeded
|
|
447
|
+
checks.append(_command_check(secrets, check_id, result))
|
|
448
|
+
|
|
449
|
+
_check_candidate_port(checks, secrets, config)
|
|
450
|
+
|
|
451
|
+
if deep:
|
|
452
|
+
for check_id, target in (
|
|
453
|
+
("database_write_probe", config.database.path.parent),
|
|
454
|
+
("state_write_probe", config.state_directory),
|
|
455
|
+
("backup_write_probe", config.backup.local_directory),
|
|
456
|
+
):
|
|
457
|
+
_check_write_probe(checks, secrets, check_id, target)
|
|
458
|
+
_check_free_space(checks, secrets, config)
|
|
459
|
+
|
|
460
|
+
if docker is not None:
|
|
461
|
+
daemon = command_runner.run(
|
|
462
|
+
(str(docker), "info", "--format", "{{json .ServerVersion}}"),
|
|
463
|
+
timeout_seconds=DOCTOR_COMMAND_TIMEOUT_SECONDS,
|
|
464
|
+
environment=command_environment,
|
|
465
|
+
working_directory=config_directory,
|
|
466
|
+
)
|
|
467
|
+
external_failed |= not daemon.succeeded
|
|
468
|
+
checks.append(_command_check(secrets, "docker_daemon", daemon))
|
|
469
|
+
|
|
470
|
+
compose = command_runner.run(
|
|
471
|
+
(
|
|
472
|
+
str(docker),
|
|
473
|
+
"compose",
|
|
474
|
+
"--file",
|
|
475
|
+
str(config.application.compose_file),
|
|
476
|
+
"config",
|
|
477
|
+
"--services",
|
|
478
|
+
),
|
|
479
|
+
timeout_seconds=DOCTOR_COMMAND_TIMEOUT_SECONDS,
|
|
480
|
+
environment=command_environment,
|
|
481
|
+
working_directory=config_directory,
|
|
482
|
+
)
|
|
483
|
+
external_failed |= not compose.succeeded
|
|
484
|
+
if compose.succeeded:
|
|
485
|
+
services = set(compose.stdout.text.splitlines())
|
|
486
|
+
if config.application.service in services:
|
|
487
|
+
checks.append(
|
|
488
|
+
_check(
|
|
489
|
+
secrets,
|
|
490
|
+
"compose_service",
|
|
491
|
+
DiagnosticOutcome.PASSED,
|
|
492
|
+
"Configured Compose service is present.",
|
|
493
|
+
{"service": config.application.service},
|
|
494
|
+
)
|
|
495
|
+
)
|
|
496
|
+
else:
|
|
497
|
+
checks.append(
|
|
498
|
+
_check(
|
|
499
|
+
secrets,
|
|
500
|
+
"compose_service",
|
|
501
|
+
DiagnosticOutcome.FAILED,
|
|
502
|
+
"Configured Compose service is absent from the Compose model.",
|
|
503
|
+
{"service": config.application.service},
|
|
504
|
+
)
|
|
505
|
+
)
|
|
506
|
+
else:
|
|
507
|
+
checks.append(_command_check(secrets, "compose_service", compose))
|
|
508
|
+
|
|
509
|
+
for check_id, message in _DEFERRED_CHECKS:
|
|
510
|
+
checks.append(_check(secrets, check_id, DiagnosticOutcome.SKIPPED, message))
|
|
511
|
+
|
|
512
|
+
failed = [check for check in checks if check.outcome is DiagnosticOutcome.FAILED]
|
|
513
|
+
report_failure: FailurePayload | None = None
|
|
514
|
+
if failed:
|
|
515
|
+
summary = ", ".join(check.check_id for check in failed)
|
|
516
|
+
error_type = ExternalCommandError if external_failed else SafetyCheckError
|
|
517
|
+
report_failure = error_type(
|
|
518
|
+
f"Doctor found {len(failed)} failed checks: {summary}.",
|
|
519
|
+
production_changed=False,
|
|
520
|
+
previous_application_running=None,
|
|
521
|
+
next_safe_action="Correct the failed checks, then run doctor again.",
|
|
522
|
+
).payload
|
|
523
|
+
return _finish_doctor(config, checked_at, deep, checks, report_failure, secrets)
|
|
524
|
+
|
|
525
|
+
|
|
526
|
+
def _check_remote_storage(
|
|
527
|
+
checks: list[DiagnosticCheck],
|
|
528
|
+
loaded: LoadedConfiguration,
|
|
529
|
+
*,
|
|
530
|
+
deep: bool,
|
|
531
|
+
environment: Mapping[str, str],
|
|
532
|
+
) -> bool:
|
|
533
|
+
remote = loaded.config.backup.remote
|
|
534
|
+
if remote is None or not remote.enabled:
|
|
535
|
+
checks.append(
|
|
536
|
+
_check(
|
|
537
|
+
loaded.secrets,
|
|
538
|
+
"remote_storage",
|
|
539
|
+
DiagnosticOutcome.SKIPPED,
|
|
540
|
+
"Remote backup storage is not enabled.",
|
|
541
|
+
)
|
|
542
|
+
)
|
|
543
|
+
return False
|
|
544
|
+
try:
|
|
545
|
+
storage = configured_s3_storage(
|
|
546
|
+
remote,
|
|
547
|
+
secrets=loaded.secrets,
|
|
548
|
+
environment=environment,
|
|
549
|
+
)
|
|
550
|
+
if deep:
|
|
551
|
+
storage.probe_access()
|
|
552
|
+
except DployDBError as error:
|
|
553
|
+
checks.append(
|
|
554
|
+
_check(
|
|
555
|
+
loaded.secrets,
|
|
556
|
+
"remote_storage",
|
|
557
|
+
DiagnosticOutcome.FAILED,
|
|
558
|
+
error.payload.what_failed,
|
|
559
|
+
{
|
|
560
|
+
"provider": remote.provider,
|
|
561
|
+
"bucket": remote.bucket,
|
|
562
|
+
"required": remote.required,
|
|
563
|
+
"access_checked": deep,
|
|
564
|
+
},
|
|
565
|
+
)
|
|
566
|
+
)
|
|
567
|
+
return True
|
|
568
|
+
checks.append(
|
|
569
|
+
_check(
|
|
570
|
+
loaded.secrets,
|
|
571
|
+
"remote_storage",
|
|
572
|
+
DiagnosticOutcome.PASSED,
|
|
573
|
+
(
|
|
574
|
+
"Remote backup credentials and read-only bucket access passed."
|
|
575
|
+
if deep
|
|
576
|
+
else "Remote backup runtime configuration is complete."
|
|
577
|
+
),
|
|
578
|
+
{
|
|
579
|
+
"provider": remote.provider,
|
|
580
|
+
"bucket": remote.bucket,
|
|
581
|
+
"required": remote.required,
|
|
582
|
+
"access_checked": deep,
|
|
583
|
+
},
|
|
584
|
+
)
|
|
585
|
+
)
|
|
586
|
+
return False
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def _finish_doctor(
|
|
590
|
+
config: DployDBConfig,
|
|
591
|
+
checked_at: str,
|
|
592
|
+
deep: bool,
|
|
593
|
+
checks: list[DiagnosticCheck],
|
|
594
|
+
failure: FailurePayload | None,
|
|
595
|
+
secrets: SecretRegistry,
|
|
596
|
+
) -> DoctorReport:
|
|
597
|
+
safe_failure = failure
|
|
598
|
+
if failure is not None:
|
|
599
|
+
safe_failure = FailurePayload(
|
|
600
|
+
error_code=secrets.redact_text(failure.error_code),
|
|
601
|
+
exit_code=failure.exit_code,
|
|
602
|
+
what_failed=secrets.redact_text(failure.what_failed),
|
|
603
|
+
production_changed=failure.production_changed,
|
|
604
|
+
previous_application_running=failure.previous_application_running,
|
|
605
|
+
recovery_required=failure.recovery_required,
|
|
606
|
+
log_path=(None if failure.log_path is None else secrets.redact_text(failure.log_path)),
|
|
607
|
+
next_safe_action=secrets.redact_text(failure.next_safe_action),
|
|
608
|
+
)
|
|
609
|
+
return DoctorReport(
|
|
610
|
+
project=secrets.redact_text(config.project),
|
|
611
|
+
checked_at=checked_at,
|
|
612
|
+
deep=deep,
|
|
613
|
+
checks=tuple(checks),
|
|
614
|
+
failure=safe_failure,
|
|
615
|
+
)
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def _check(
|
|
619
|
+
secrets: SecretRegistry,
|
|
620
|
+
check_id: str,
|
|
621
|
+
outcome: DiagnosticOutcome,
|
|
622
|
+
message: str,
|
|
623
|
+
evidence: Mapping[str, Any] | None = None,
|
|
624
|
+
) -> DiagnosticCheck:
|
|
625
|
+
raw = secrets.redact(dict(evidence or {}))
|
|
626
|
+
assert isinstance(raw, dict)
|
|
627
|
+
return DiagnosticCheck(
|
|
628
|
+
check_id=check_id,
|
|
629
|
+
outcome=outcome,
|
|
630
|
+
message=secrets.redact_text(message),
|
|
631
|
+
evidence=raw,
|
|
632
|
+
)
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
def _lock_evidence(lock: LockInspection, secrets: SecretRegistry) -> dict[str, JsonValue]:
|
|
636
|
+
owner: dict[str, JsonValue] | None = None
|
|
637
|
+
if lock.owner is not None:
|
|
638
|
+
owner = {
|
|
639
|
+
"owner_id": lock.owner.owner_id,
|
|
640
|
+
"operation_id": lock.owner.operation_id,
|
|
641
|
+
"operation_type": lock.owner.operation_type,
|
|
642
|
+
"pid": lock.owner.process.pid,
|
|
643
|
+
"hostname": secrets.redact_text(lock.owner.process.hostname),
|
|
644
|
+
"state": lock.owner.state.value,
|
|
645
|
+
"acquired_at": lock.owner.model_dump(mode="json")["acquired_at"],
|
|
646
|
+
"released_at": lock.owner.model_dump(mode="json")["released_at"],
|
|
647
|
+
}
|
|
648
|
+
return {
|
|
649
|
+
"state": lock.state.value,
|
|
650
|
+
"held": lock.lock_held,
|
|
651
|
+
"owner": owner,
|
|
652
|
+
"metadata_error": (
|
|
653
|
+
None if lock.metadata_error is None else secrets.redact_text(lock.metadata_error)
|
|
654
|
+
),
|
|
655
|
+
"lock_path": secrets.redact_text(str(lock.lock_path)),
|
|
656
|
+
"owner_path": secrets.redact_text(str(lock.owner_path)),
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def _operation_evidence(
|
|
661
|
+
operation: OperationManifest | None,
|
|
662
|
+
secrets: SecretRegistry,
|
|
663
|
+
) -> dict[str, JsonValue] | None:
|
|
664
|
+
if operation is None:
|
|
665
|
+
return None
|
|
666
|
+
raw: dict[str, JsonValue] = {
|
|
667
|
+
"operation_id": operation.operation_id,
|
|
668
|
+
"operation_type": operation.operation_type,
|
|
669
|
+
"status": operation.status.value,
|
|
670
|
+
"stage": operation.stage,
|
|
671
|
+
"started_at": operation.model_dump(mode="json")["started_at"],
|
|
672
|
+
"updated_at": operation.model_dump(mode="json")["updated_at"],
|
|
673
|
+
"production_changed": operation.safety.production_changed,
|
|
674
|
+
"previous_application_running": operation.safety.previous_application_running,
|
|
675
|
+
"recovery_required": operation.safety.recovery_required,
|
|
676
|
+
}
|
|
677
|
+
redacted = secrets.redact(raw)
|
|
678
|
+
assert isinstance(redacted, dict)
|
|
679
|
+
return redacted
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def _interrupted_status(
|
|
683
|
+
*,
|
|
684
|
+
config: DployDBConfig,
|
|
685
|
+
checked_at: str,
|
|
686
|
+
lock: dict[str, JsonValue],
|
|
687
|
+
operation: OperationManifest,
|
|
688
|
+
secrets: SecretRegistry,
|
|
689
|
+
detail: str,
|
|
690
|
+
) -> StatusReport:
|
|
691
|
+
log_path = (
|
|
692
|
+
StateStore(config.state_directory, secrets=secrets)
|
|
693
|
+
.operation_paths(operation.operation_id)
|
|
694
|
+
.events
|
|
695
|
+
)
|
|
696
|
+
failure = RecoveryRequiredError(
|
|
697
|
+
secrets.redact_text(detail),
|
|
698
|
+
production_changed=operation.safety.production_changed,
|
|
699
|
+
previous_application_running=operation.safety.previous_application_running,
|
|
700
|
+
log_path=secrets.redact_text(str(log_path)),
|
|
701
|
+
next_safe_action=(
|
|
702
|
+
"Preserve the operation and lock evidence. Inspect the recorded operation before "
|
|
703
|
+
"retrying; do not delete the lock or state files."
|
|
704
|
+
),
|
|
705
|
+
).payload
|
|
706
|
+
return StatusReport(
|
|
707
|
+
project=secrets.redact_text(config.project),
|
|
708
|
+
checked_at=checked_at,
|
|
709
|
+
status=RuntimeStatus.INTERRUPTED,
|
|
710
|
+
lock=lock,
|
|
711
|
+
operation=_operation_evidence(operation, secrets),
|
|
712
|
+
warnings=(),
|
|
713
|
+
next_safe_action=failure.next_safe_action,
|
|
714
|
+
failure=failure,
|
|
715
|
+
)
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def _recovery_status(
|
|
719
|
+
*,
|
|
720
|
+
config: DployDBConfig,
|
|
721
|
+
checked_at: str,
|
|
722
|
+
lock: dict[str, JsonValue],
|
|
723
|
+
operation: OperationManifest | None,
|
|
724
|
+
secrets: SecretRegistry,
|
|
725
|
+
what_failed: str,
|
|
726
|
+
log_path: str | Path | None,
|
|
727
|
+
) -> StatusReport:
|
|
728
|
+
production_changed = True
|
|
729
|
+
previous_application_running: bool | None = None
|
|
730
|
+
if operation is not None:
|
|
731
|
+
production_changed = operation.safety.production_changed
|
|
732
|
+
previous_application_running = operation.safety.previous_application_running
|
|
733
|
+
failure = RecoveryRequiredError(
|
|
734
|
+
secrets.redact_text(what_failed),
|
|
735
|
+
production_changed=production_changed,
|
|
736
|
+
previous_application_running=previous_application_running,
|
|
737
|
+
log_path=None if log_path is None else secrets.redact_text(str(log_path)),
|
|
738
|
+
next_safe_action=(
|
|
739
|
+
"Preserve the operation and lock evidence. Inspect the recorded state before "
|
|
740
|
+
"retrying; do not delete or repair files by guesswork."
|
|
741
|
+
),
|
|
742
|
+
).payload
|
|
743
|
+
return StatusReport(
|
|
744
|
+
project=secrets.redact_text(config.project),
|
|
745
|
+
checked_at=checked_at,
|
|
746
|
+
status=RuntimeStatus.RECOVERY_REQUIRED,
|
|
747
|
+
lock=lock,
|
|
748
|
+
operation=_operation_evidence(operation, secrets),
|
|
749
|
+
warnings=(),
|
|
750
|
+
next_safe_action=failure.next_safe_action,
|
|
751
|
+
failure=failure,
|
|
752
|
+
)
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def _check_regular_readable(
|
|
756
|
+
checks: list[DiagnosticCheck],
|
|
757
|
+
secrets: SecretRegistry,
|
|
758
|
+
check_id: str,
|
|
759
|
+
path: Path,
|
|
760
|
+
) -> bool:
|
|
761
|
+
try:
|
|
762
|
+
details = path.stat()
|
|
763
|
+
valid = stat.S_ISREG(details.st_mode) and os.access(path, os.R_OK)
|
|
764
|
+
except OSError:
|
|
765
|
+
valid = False
|
|
766
|
+
checks.append(
|
|
767
|
+
_check(
|
|
768
|
+
secrets,
|
|
769
|
+
check_id,
|
|
770
|
+
DiagnosticOutcome.PASSED if valid else DiagnosticOutcome.FAILED,
|
|
771
|
+
"Required file is readable and regular."
|
|
772
|
+
if valid
|
|
773
|
+
else f"Required file is missing, unreadable, or not regular: {path}",
|
|
774
|
+
{"path": str(path)},
|
|
775
|
+
)
|
|
776
|
+
)
|
|
777
|
+
return valid
|
|
778
|
+
|
|
779
|
+
|
|
780
|
+
def _nearest_existing_directory(path: Path) -> Path | None:
|
|
781
|
+
current = path
|
|
782
|
+
while not current.exists() and current != current.parent:
|
|
783
|
+
current = current.parent
|
|
784
|
+
return current if current.exists() and current.is_dir() else None
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
def _check_directory_destination(
|
|
788
|
+
checks: list[DiagnosticCheck],
|
|
789
|
+
secrets: SecretRegistry,
|
|
790
|
+
check_id: str,
|
|
791
|
+
path: Path,
|
|
792
|
+
*,
|
|
793
|
+
required_mode: int | None,
|
|
794
|
+
) -> None:
|
|
795
|
+
valid = False
|
|
796
|
+
message: str
|
|
797
|
+
try:
|
|
798
|
+
if path.exists():
|
|
799
|
+
details = path.stat()
|
|
800
|
+
mode = stat.S_IMODE(details.st_mode)
|
|
801
|
+
valid = (
|
|
802
|
+
not path.is_symlink()
|
|
803
|
+
and stat.S_ISDIR(details.st_mode)
|
|
804
|
+
and os.access(path, os.W_OK | os.X_OK)
|
|
805
|
+
and (required_mode is None or mode == required_mode)
|
|
806
|
+
)
|
|
807
|
+
message = (
|
|
808
|
+
"Directory exists and is writable."
|
|
809
|
+
if valid
|
|
810
|
+
else f"Directory is unsafe or not writable: {path}"
|
|
811
|
+
)
|
|
812
|
+
else:
|
|
813
|
+
parent = _nearest_existing_directory(path.parent)
|
|
814
|
+
valid = parent is not None and os.access(parent, os.W_OK | os.X_OK)
|
|
815
|
+
message = (
|
|
816
|
+
"Directory does not exist but its nearest parent is writable."
|
|
817
|
+
if valid
|
|
818
|
+
else f"Directory cannot be created safely: {path}"
|
|
819
|
+
)
|
|
820
|
+
except OSError:
|
|
821
|
+
message = f"Directory could not be inspected: {path}"
|
|
822
|
+
checks.append(
|
|
823
|
+
_check(
|
|
824
|
+
secrets,
|
|
825
|
+
check_id,
|
|
826
|
+
DiagnosticOutcome.PASSED if valid else DiagnosticOutcome.FAILED,
|
|
827
|
+
message,
|
|
828
|
+
{"path": str(path)},
|
|
829
|
+
)
|
|
830
|
+
)
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
def _resolve_executable(
|
|
834
|
+
executable: str,
|
|
835
|
+
config_directory: Path,
|
|
836
|
+
environment: Mapping[str, str],
|
|
837
|
+
) -> Path | None:
|
|
838
|
+
if "/" not in executable:
|
|
839
|
+
resolved = shutil.which(executable, path=environment.get("PATH"))
|
|
840
|
+
return None if resolved is None else Path(resolved)
|
|
841
|
+
candidate = Path(executable)
|
|
842
|
+
if not candidate.is_absolute():
|
|
843
|
+
candidate = config_directory / candidate
|
|
844
|
+
try:
|
|
845
|
+
details = candidate.stat()
|
|
846
|
+
except OSError:
|
|
847
|
+
return None
|
|
848
|
+
if not stat.S_ISREG(details.st_mode) or not os.access(candidate, os.X_OK):
|
|
849
|
+
return None
|
|
850
|
+
return candidate.absolute()
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
def _command_check(
|
|
854
|
+
secrets: SecretRegistry,
|
|
855
|
+
check_id: str,
|
|
856
|
+
result: CommandResult,
|
|
857
|
+
) -> DiagnosticCheck:
|
|
858
|
+
message = (
|
|
859
|
+
"Bounded command completed successfully."
|
|
860
|
+
if result.succeeded
|
|
861
|
+
else f"Bounded command did not succeed ({result.outcome.value})."
|
|
862
|
+
)
|
|
863
|
+
return _check(
|
|
864
|
+
secrets,
|
|
865
|
+
check_id,
|
|
866
|
+
DiagnosticOutcome.PASSED if result.succeeded else DiagnosticOutcome.FAILED,
|
|
867
|
+
message,
|
|
868
|
+
result.as_evidence(),
|
|
869
|
+
)
|
|
870
|
+
|
|
871
|
+
|
|
872
|
+
def _check_candidate_port(
|
|
873
|
+
checks: list[DiagnosticCheck],
|
|
874
|
+
secrets: SecretRegistry,
|
|
875
|
+
config: DployDBConfig,
|
|
876
|
+
) -> None:
|
|
877
|
+
host = urlsplit(config.application.candidate_health_url).hostname
|
|
878
|
+
available = host is not None
|
|
879
|
+
error: str | None = None
|
|
880
|
+
if host is not None:
|
|
881
|
+
try:
|
|
882
|
+
addresses = socket.getaddrinfo(
|
|
883
|
+
host,
|
|
884
|
+
config.application.candidate_port,
|
|
885
|
+
type=socket.SOCK_STREAM,
|
|
886
|
+
)
|
|
887
|
+
for family, socket_type, protocol, _canonical, address in addresses:
|
|
888
|
+
probe = socket.socket(family, socket_type, protocol)
|
|
889
|
+
try:
|
|
890
|
+
probe.bind(address)
|
|
891
|
+
finally:
|
|
892
|
+
probe.close()
|
|
893
|
+
except OSError as exc:
|
|
894
|
+
available = False
|
|
895
|
+
error = secrets.redact_text(str(exc))
|
|
896
|
+
checks.append(
|
|
897
|
+
_check(
|
|
898
|
+
secrets,
|
|
899
|
+
"candidate_port",
|
|
900
|
+
DiagnosticOutcome.PASSED if available else DiagnosticOutcome.FAILED,
|
|
901
|
+
"Candidate port is available."
|
|
902
|
+
if available
|
|
903
|
+
else "Candidate port is unavailable on the configured loopback host.",
|
|
904
|
+
{
|
|
905
|
+
"host": host,
|
|
906
|
+
"port": config.application.candidate_port,
|
|
907
|
+
"error": error,
|
|
908
|
+
},
|
|
909
|
+
)
|
|
910
|
+
)
|
|
911
|
+
|
|
912
|
+
|
|
913
|
+
def _check_write_probe(
|
|
914
|
+
checks: list[DiagnosticCheck],
|
|
915
|
+
secrets: SecretRegistry,
|
|
916
|
+
check_id: str,
|
|
917
|
+
destination: Path,
|
|
918
|
+
) -> None:
|
|
919
|
+
directory = destination if destination.exists() else _nearest_existing_directory(destination)
|
|
920
|
+
error: str | None = None
|
|
921
|
+
temporary: Path | None = None
|
|
922
|
+
descriptor = -1
|
|
923
|
+
if directory is None:
|
|
924
|
+
error = "No existing parent directory is available for the write probe."
|
|
925
|
+
else:
|
|
926
|
+
temporary = directory / f".dploydb-doctor-{uuid4().hex}.tmp"
|
|
927
|
+
try:
|
|
928
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
|
929
|
+
flags |= getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
930
|
+
descriptor = os.open(temporary, flags, 0o600)
|
|
931
|
+
os.write(descriptor, b"dploydb-doctor\n")
|
|
932
|
+
os.fsync(descriptor)
|
|
933
|
+
os.close(descriptor)
|
|
934
|
+
descriptor = -1
|
|
935
|
+
temporary.unlink()
|
|
936
|
+
temporary = None
|
|
937
|
+
except OSError as exc:
|
|
938
|
+
error = secrets.redact_text(str(exc))
|
|
939
|
+
finally:
|
|
940
|
+
if descriptor >= 0:
|
|
941
|
+
try:
|
|
942
|
+
os.close(descriptor)
|
|
943
|
+
except OSError:
|
|
944
|
+
pass
|
|
945
|
+
if temporary is not None:
|
|
946
|
+
try:
|
|
947
|
+
temporary.unlink(missing_ok=True)
|
|
948
|
+
except OSError as cleanup_error:
|
|
949
|
+
cleanup = secrets.redact_text(str(cleanup_error))
|
|
950
|
+
error = f"{error or 'write probe failed'}; cleanup failed: {cleanup}"
|
|
951
|
+
checks.append(
|
|
952
|
+
_check(
|
|
953
|
+
secrets,
|
|
954
|
+
check_id,
|
|
955
|
+
DiagnosticOutcome.PASSED if error is None else DiagnosticOutcome.FAILED,
|
|
956
|
+
"Temporary write and cleanup probe passed."
|
|
957
|
+
if error is None
|
|
958
|
+
else "Temporary write or cleanup probe failed.",
|
|
959
|
+
{"destination": str(destination), "error": error},
|
|
960
|
+
)
|
|
961
|
+
)
|
|
962
|
+
|
|
963
|
+
|
|
964
|
+
def _check_free_space(
|
|
965
|
+
checks: list[DiagnosticCheck],
|
|
966
|
+
secrets: SecretRegistry,
|
|
967
|
+
config: DployDBConfig,
|
|
968
|
+
) -> None:
|
|
969
|
+
try:
|
|
970
|
+
database_size = config.database.path.stat().st_size
|
|
971
|
+
required = math.ceil(database_size * config.database.minimum_free_space_multiplier)
|
|
972
|
+
except OSError as exc:
|
|
973
|
+
checks.append(
|
|
974
|
+
_check(
|
|
975
|
+
secrets,
|
|
976
|
+
"disk_space",
|
|
977
|
+
DiagnosticOutcome.FAILED,
|
|
978
|
+
"Database size could not be inspected for the disk-space check.",
|
|
979
|
+
{"error": str(exc)},
|
|
980
|
+
)
|
|
981
|
+
)
|
|
982
|
+
return
|
|
983
|
+
|
|
984
|
+
filesystems: dict[int, tuple[Path, int]] = {}
|
|
985
|
+
error: str | None = None
|
|
986
|
+
try:
|
|
987
|
+
for target in (
|
|
988
|
+
config.database.path.parent,
|
|
989
|
+
config.state_directory,
|
|
990
|
+
config.backup.local_directory,
|
|
991
|
+
):
|
|
992
|
+
directory = target if target.exists() else _nearest_existing_directory(target)
|
|
993
|
+
if directory is None:
|
|
994
|
+
raise OSError(f"no existing parent for {target}")
|
|
995
|
+
device = directory.stat().st_dev
|
|
996
|
+
free = shutil.disk_usage(directory).free
|
|
997
|
+
filesystems[device] = (directory, free)
|
|
998
|
+
except OSError as exc:
|
|
999
|
+
error = secrets.redact_text(str(exc))
|
|
1000
|
+
|
|
1001
|
+
evidence = {
|
|
1002
|
+
"database_size_bytes": database_size,
|
|
1003
|
+
"required_free_bytes": required,
|
|
1004
|
+
"filesystems": [
|
|
1005
|
+
{"path": str(path), "free_bytes": free} for path, free in filesystems.values()
|
|
1006
|
+
],
|
|
1007
|
+
"error": error,
|
|
1008
|
+
}
|
|
1009
|
+
sufficient = error is None and all(free >= required for _path, free in filesystems.values())
|
|
1010
|
+
checks.append(
|
|
1011
|
+
_check(
|
|
1012
|
+
secrets,
|
|
1013
|
+
"disk_space",
|
|
1014
|
+
DiagnosticOutcome.PASSED if sufficient else DiagnosticOutcome.FAILED,
|
|
1015
|
+
"Relevant filesystems meet the configured free-space threshold."
|
|
1016
|
+
if sufficient
|
|
1017
|
+
else "A relevant filesystem does not meet the configured free-space threshold.",
|
|
1018
|
+
evidence,
|
|
1019
|
+
)
|
|
1020
|
+
)
|