Skip to content

Commit d161d45

Browse files
committed
feat: add bench diagnostics
1 parent 8d5de6e commit d161d45

8 files changed

Lines changed: 553 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,7 @@ Each bench lives on a single dataset (`<pool>/<bench>`) holding both its files a
195195
| `bench start` | Start all processes (web, workers, Redis, admin UI) |
196196
| `bench stop` | Stop a running bench from another terminal |
197197
| `bench restart` | Restart all processes — supervisor, systemd, or OpenRC (production only) |
198+
| `bench diagnostics` | Run health checks for resources, processes, Redis, database reachability, and workers |
198199
| `bench get-app <repo>` | Clone and install an app |
199200
| `bench new-site <name>` | Create a site |
200201
| `bench rename-site <old> <new>` | Rename a site (checks the hostname is free across all benches) |

admin/backend/app.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from .views.benches import benches_bp
1515
from .views.dashboard import dashboard_bp
1616
from .views.database import database_bp
17+
from .views.diagnostics import diagnostics_bp
1718
from .views.git import git_bp
1819
from .views.logs import logs_bp
1920
from .views.processes import processes_bp
@@ -217,6 +218,7 @@ def api_logout():
217218
app.register_blueprint(processes_bp, url_prefix="/api/processes")
218219
app.register_blueprint(logs_bp, url_prefix="/api/logs")
219220
app.register_blueprint(database_bp, url_prefix="/api/database")
221+
app.register_blueprint(diagnostics_bp, url_prefix="/api/diagnostics")
220222
app.register_blueprint(tasks_bp, url_prefix="/api/tasks")
221223
app.register_blueprint(settings_bp, url_prefix="/api/settings")
222224
app.register_blueprint(updates_bp, url_prefix="/api/updates")

admin/backend/views/diagnostics.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
from __future__ import annotations
2+
3+
import json
4+
5+
from flask import Blueprint, current_app, jsonify
6+
7+
from pilot.config.toml_store import BenchTomlStore
8+
from pilot.core.bench import Bench
9+
from pilot.core.diagnostics import DiagnosticReport, DiagnosticRunner
10+
11+
diagnostics_bp = Blueprint("diagnostics", __name__)
12+
13+
14+
@diagnostics_bp.route("/")
15+
def index():
16+
bench_root = current_app.config["BENCH_ROOT"]
17+
try:
18+
config = BenchTomlStore.for_bench(bench_root).read()
19+
checks = DiagnosticRunner(Bench(config, bench_root)).run()
20+
except Exception as error:
21+
return jsonify({"error": str(error)}), 500
22+
return jsonify(json.loads(DiagnosticReport(config.name, checks).to_json()))

docs/commands.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,25 @@ Lists every bench found under `benches/`, with its runtime status (running/stopp
337337

338338
---
339339

340+
## `bench diagnostics`
341+
342+
Runs read-only health checks for the active bench.
343+
344+
```bash
345+
bench diagnostics
346+
bench diagnostics --json
347+
```
348+
349+
Checks cover bench structure, generated config files, site config JSON, core
350+
runtime dependencies, CPU load, disk space, Linux memory, process state, Redis
351+
ports, database reachability, workers, and Chromium availability for PDF
352+
generation. The command does not install packages, start services, modify
353+
configuration, or run migrations.
354+
355+
The Admin backend exposes the same report at `GET /api/diagnostics/`.
356+
357+
---
358+
340359
## `bench stop`
341360

342361
Stops a running bench. `ProcessManagerFactory.detect_running()` picks the right manager (dev runner, systemd, or supervisor), then stops the workload and the admin service. Does **not** rely on `pids/bench.pid` — a dev bench with no PID file is stopped by killing the port-bound processes. Works across terminal sessions.

pilot/commands/diagnostics.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
from __future__ import annotations
2+
3+
import argparse
4+
5+
from pilot.commands.base import Command
6+
7+
8+
class DiagnosticsCommand(Command):
9+
name = "diagnostics"
10+
help = "Run health checks for the bench."
11+
12+
@classmethod
13+
def add_arguments(cls, parser: argparse.ArgumentParser) -> None:
14+
parser.add_argument("--json", action="store_true", help="Print diagnostics as JSON.")
15+
16+
@classmethod
17+
def from_args(cls, args: argparse.Namespace, bench):
18+
return cls(bench, json_output=args.json)
19+
20+
def __init__(self, bench, json_output: bool = False) -> None:
21+
self.bench = bench
22+
self.json_output = json_output
23+
24+
def run(self) -> None:
25+
from pilot.core.diagnostics import DiagnosticReport, DiagnosticRunner
26+
27+
report = DiagnosticReport(self.bench.config.name, DiagnosticRunner(self.bench).run())
28+
if self.json_output:
29+
print(report.to_json())
30+
return
31+
self._print(report)
32+
33+
def _print(self, report: DiagnosticReport) -> None:
34+
print(f"Diagnostics for {report.bench_name}\n")
35+
current_group = ""
36+
for check in report.checks:
37+
if check.group != current_group:
38+
current_group = check.group
39+
print(_group_title(current_group))
40+
print(f" {_badge(check.status)} {check.name}: {check.detail}")
41+
if check.hint:
42+
print(f" {check.hint}")
43+
print("\nResult: " + ("issues found" if report.failed else "all checks passed"))
44+
45+
46+
def _group_title(group: str) -> str:
47+
return f"{group.capitalize()}:"
48+
49+
50+
def _badge(status: str) -> str:
51+
icons = {"ok": _green("OK"), "warn": _yellow("WARN"), "fail": _red("FAIL"), "skip": _dim("SKIP")}
52+
return icons.get(status, status.upper())
53+
54+
55+
def _green(text: str) -> str:
56+
return f"\033[32m{text}\033[0m"
57+
58+
59+
def _yellow(text: str) -> str:
60+
return f"\033[33m{text}\033[0m"
61+
62+
63+
def _red(text: str) -> str:
64+
return f"\033[31m{text}\033[0m"
65+
66+
67+
def _dim(text: str) -> str:
68+
return f"\033[90m{text}\033[0m"

pilot/core/diagnostics.py

Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
from __future__ import annotations
2+
3+
import json
4+
import os
5+
import shutil
6+
import socket
7+
from dataclasses import dataclass
8+
from pathlib import Path
9+
from typing import TYPE_CHECKING
10+
11+
if TYPE_CHECKING:
12+
from pilot.core.bench import Bench
13+
14+
15+
@dataclass
16+
class DiagnosticCheck:
17+
group: str
18+
name: str
19+
status: str
20+
detail: str
21+
hint: str = ""
22+
23+
def to_dict(self) -> dict[str, str]:
24+
data = {"group": self.group, "name": self.name, "status": self.status, "detail": self.detail}
25+
if self.hint:
26+
data["hint"] = self.hint
27+
return data
28+
29+
30+
class DiagnosticRunner:
31+
def __init__(self, bench: "Bench") -> None:
32+
self.bench = bench
33+
34+
def run(self) -> list[DiagnosticCheck]:
35+
checks: list[DiagnosticCheck] = []
36+
checks.extend(self._bench_checks())
37+
checks.extend(self._configuration_checks())
38+
checks.extend(self._site_checks())
39+
checks.extend(self._dependency_checks())
40+
checks.extend(self._resource_checks())
41+
checks.extend(self._process_checks())
42+
checks.extend(self._redis_checks())
43+
checks.extend(self._database_checks())
44+
checks.extend(self._worker_checks())
45+
return checks
46+
47+
def _bench_checks(self) -> list[DiagnosticCheck]:
48+
return [
49+
self._path_check("bench", "bench.toml", self.bench.path / "bench.toml", "Run bench new <name>."),
50+
self._path_check("bench", "python env", self.bench.python, "Run bench init."),
51+
self._path_check("bench", "apps directory", self.bench.apps_path, "Run bench init."),
52+
self._path_check("bench", "sites directory", self.bench.sites_path, "Run bench init."),
53+
self._path_check("bench", "config directory", self.bench.config_path, "Run bench setup config."),
54+
]
55+
56+
def _configuration_checks(self) -> list[DiagnosticCheck]:
57+
checks = [
58+
self._path_check("config", "common site config", self.bench.sites_path / "common_site_config.json", "Run bench setup config."),
59+
self._path_check("config", "procfile", self.bench.config_path / "Procfile", "Run bench setup config."),
60+
]
61+
if self.bench.config.admin.enabled and not self.bench.config.admin.password:
62+
checks.append(DiagnosticCheck("config", "admin password", "fail", "admin is enabled without a password", "Run bench set-admin-password."))
63+
elif self.bench.config.admin.enabled:
64+
checks.append(DiagnosticCheck("config", "admin password", "ok", "configured"))
65+
else:
66+
checks.append(DiagnosticCheck("config", "admin", "warn", "admin UI is disabled"))
67+
return checks
68+
69+
def _site_checks(self) -> list[DiagnosticCheck]:
70+
if not self.bench.sites_path.exists():
71+
return [DiagnosticCheck("sites", "sites", "fail", f"missing: {self.bench.sites_path}", "Run bench init.")]
72+
site_dirs = [p for p in sorted(self.bench.sites_path.iterdir()) if (p / "site_config.json").exists()]
73+
if not site_dirs:
74+
return [DiagnosticCheck("sites", "sites", "warn", "no sites found", "Create one with bench new-site <name>.")]
75+
checks = [DiagnosticCheck("sites", "site count", "ok", f"{len(site_dirs)} site(s) found")]
76+
checks.extend(self._site_config_check(site_dir) for site_dir in site_dirs)
77+
return checks
78+
79+
def _dependency_checks(self) -> list[DiagnosticCheck]:
80+
checks = [
81+
self._executable_check("dependencies", "redis-server", "redis-server", "Install Redis or run bench init."),
82+
self._executable_check("dependencies", "node", "node", "Install Node.js or run bench init."),
83+
]
84+
checks.append(self._chromium_check())
85+
return checks
86+
87+
def _resource_checks(self) -> list[DiagnosticCheck]:
88+
return [
89+
self._cpu_check(),
90+
self._disk_check(),
91+
self._memory_check(),
92+
]
93+
94+
def _process_checks(self) -> list[DiagnosticCheck]:
95+
from pilot.managers.process_manager import ProcessManager
96+
97+
try:
98+
manager = ProcessManager.detect_running(self.bench)
99+
workload = manager.is_running()
100+
admin = manager.is_admin_running()
101+
except Exception as exc:
102+
return [DiagnosticCheck("process", "process manager", "fail", str(exc))]
103+
if workload:
104+
return [DiagnosticCheck("process", "bench workload", "ok", "workload is running")]
105+
if admin:
106+
return [DiagnosticCheck("process", "admin", "warn", "admin is running, workload is stopped", "Run bench start or setup production.")]
107+
return [DiagnosticCheck("process", "bench workload", "fail", "bench is not running", "Run bench start.")]
108+
109+
def _redis_checks(self) -> list[DiagnosticCheck]:
110+
redis = self.bench.config.redis
111+
return [
112+
self._port_check("redis", "cache", "127.0.0.1", redis.cache_port),
113+
self._port_check("redis", "queue", "127.0.0.1", redis.queue_port),
114+
]
115+
116+
def _database_checks(self) -> list[DiagnosticCheck]:
117+
db_type = self.bench.config.db_type
118+
if db_type == "sqlite":
119+
return [DiagnosticCheck("database", "sqlite", "skip", "sqlite has no shared database server")]
120+
if db_type == "postgres":
121+
pg = self.bench.config.postgres
122+
return [self._port_check("database", "postgres", pg.host, pg.port)]
123+
mdb = self.bench.config.mariadb
124+
if mdb.socket_path:
125+
return [self._socket_check("database", "mariadb socket", Path(mdb.socket_path))]
126+
return [self._port_check("database", "mariadb", mdb.host, mdb.port)]
127+
128+
def _worker_checks(self) -> list[DiagnosticCheck]:
129+
workers = [p for p in self.bench.pids_path.glob("worker*.pid")] if self.bench.pids_path.exists() else []
130+
if workers:
131+
live = sum(1 for path in workers if self._pid_alive(path))
132+
status = "ok" if live else "fail"
133+
return [DiagnosticCheck("workers", "worker pids", status, f"{live}/{len(workers)} worker pid files are live")]
134+
if self.bench.config.production.use_companion_manager:
135+
return [DiagnosticCheck("workers", "workers", "skip", "workers run inside the companion web process")]
136+
return [DiagnosticCheck("workers", "worker pids", "warn", "no worker pid files found", "Start the bench and re-run diagnostics.")]
137+
138+
def _disk_check(self) -> DiagnosticCheck:
139+
usage = shutil.disk_usage(self.bench.path)
140+
free_gb = usage.free / 1024**3
141+
used_pct = (usage.used / usage.total) * 100 if usage.total else 0
142+
detail = f"{free_gb:.1f} GB free, {used_pct:.0f}% used at {self.bench.path}"
143+
if free_gb < 1:
144+
return DiagnosticCheck("resources", "disk", "fail", detail, "Free disk space before running migrations or backups.")
145+
if free_gb < 5:
146+
return DiagnosticCheck("resources", "disk", "warn", detail, "Keep at least 5 GB free for updates and backups.")
147+
return DiagnosticCheck("resources", "disk", "ok", detail)
148+
149+
def _cpu_check(self) -> DiagnosticCheck:
150+
try:
151+
load_1m = os.getloadavg()[0]
152+
except (AttributeError, OSError):
153+
return DiagnosticCheck("resources", "cpu load", "skip", "load average is not available")
154+
cpu_count = os.cpu_count() or 1
155+
detail = f"1m load {load_1m:.2f} across {cpu_count} CPU(s)"
156+
if load_1m > cpu_count * 2:
157+
return DiagnosticCheck("resources", "cpu load", "fail", detail, "Server is heavily loaded.")
158+
if load_1m > cpu_count:
159+
return DiagnosticCheck("resources", "cpu load", "warn", detail, "CPU pressure may slow requests and jobs.")
160+
return DiagnosticCheck("resources", "cpu load", "ok", detail)
161+
162+
def _memory_check(self) -> DiagnosticCheck:
163+
memory = self._linux_memory()
164+
if not memory:
165+
return DiagnosticCheck("resources", "memory", "skip", "memory check is only available on Linux")
166+
available_gb = memory["available"] / 1024**2
167+
detail = f"{available_gb:.1f} GB available"
168+
if available_gb < 0.5:
169+
return DiagnosticCheck("resources", "memory", "fail", detail, "Free memory or add swap before starting workers.")
170+
if available_gb < 1:
171+
return DiagnosticCheck("resources", "memory", "warn", detail, "Low memory can make builds and migrations unreliable.")
172+
return DiagnosticCheck("resources", "memory", "ok", detail)
173+
174+
def _path_check(self, group: str, name: str, path: Path, hint: str) -> DiagnosticCheck:
175+
if path.exists():
176+
return DiagnosticCheck(group, name, "ok", str(path))
177+
return DiagnosticCheck(group, name, "fail", f"missing: {path}", hint)
178+
179+
def _port_check(self, group: str, name: str, host: str, port: int) -> DiagnosticCheck:
180+
if self._tcp_open(host, port):
181+
return DiagnosticCheck(group, name, "ok", f"{host}:{port} is reachable")
182+
return DiagnosticCheck(group, name, "fail", f"{host}:{port} is not reachable")
183+
184+
def _socket_check(self, group: str, name: str, path: Path) -> DiagnosticCheck:
185+
if path.exists():
186+
return DiagnosticCheck(group, name, "ok", str(path))
187+
return DiagnosticCheck(group, name, "fail", f"missing: {path}", "Start the database service.")
188+
189+
def _executable_check(self, group: str, name: str, executable: str, hint: str) -> DiagnosticCheck:
190+
path = shutil.which(executable)
191+
if path:
192+
return DiagnosticCheck(group, name, "ok", path)
193+
return DiagnosticCheck(group, name, "fail", f"{executable} not found in PATH", hint)
194+
195+
def _chromium_check(self) -> DiagnosticCheck:
196+
candidates = ["chromium", "chromium-browser", "google-chrome", "google-chrome-stable"]
197+
found = next((path for name in candidates if (path := shutil.which(name))), "")
198+
if found:
199+
return DiagnosticCheck("dependencies", "chromium", "ok", found)
200+
return DiagnosticCheck("dependencies", "chromium", "warn", "not found in PATH", "PDF generation may fail until Chromium is installed.")
201+
202+
def _site_config_check(self, site_dir: Path) -> DiagnosticCheck:
203+
path = site_dir / "site_config.json"
204+
try:
205+
json.loads(path.read_text())
206+
except Exception as exc:
207+
return DiagnosticCheck("sites", site_dir.name, "fail", f"invalid site_config.json: {exc}")
208+
return DiagnosticCheck("sites", site_dir.name, "ok", "site_config.json is valid")
209+
210+
def _pid_alive(self, path: Path) -> bool:
211+
try:
212+
os.kill(int(path.read_text().strip()), 0)
213+
return True
214+
except (ValueError, ProcessLookupError, PermissionError, OSError):
215+
return False
216+
217+
def _linux_memory(self) -> dict[str, int]:
218+
path = Path("/proc/meminfo")
219+
if not path.exists():
220+
return {}
221+
fields = {}
222+
for line in path.read_text().splitlines():
223+
key, value = line.split(":", 1)
224+
fields[key] = int(value.strip().split()[0])
225+
return {"available": fields.get("MemAvailable", 0)}
226+
227+
def _tcp_open(self, host: str, port: int) -> bool:
228+
try:
229+
with socket.create_connection((host, port), timeout=0.5):
230+
return True
231+
except OSError:
232+
return False
233+
234+
235+
class DiagnosticReport:
236+
def __init__(self, bench_name: str, checks: list[DiagnosticCheck]) -> None:
237+
self.bench_name = bench_name
238+
self.checks = checks
239+
240+
@property
241+
def failed(self) -> bool:
242+
return any(check.status == "fail" for check in self.checks)
243+
244+
def to_json(self) -> str:
245+
data = {"bench": self.bench_name, "checks": [check.to_dict() for check in self.checks]}
246+
return json.dumps(data, indent=2)

0 commit comments

Comments
 (0)