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