Files

185 lines
6.1 KiB
PHP

<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Redis;
/**
* Health check every few minutes, reported to healthchecks.io.
*
* On 2026-10-07 MariaDB was killed by the kernel's OOM killer at 02:09 and the
* site served errors for 14 hours before anyone noticed: nothing watched
* whether it was up. This checks the services the site needs and reports the
* outcome to a healthchecks.io check:
*
* - all fine: a plain ping;
* - something failed: a ping to /fail with what failed, which alerts at once;
* - no ping at all (server down, scheduler broken): healthchecks.io alerts
* once the check's grace period has passed.
*
* Every check catches its own errors and has a short timeout, so one dead
* service is reported rather than stopping the run. Failures also go to the
* Laravel log. Scheduled in app/Console/Kernel.php; config in
* `health_monitor.*` (config/timebank_cc.php).
*/
class HealthCheck extends Command
{
protected $signature = 'health:check
{--dry-run : Print the results without pinging healthchecks.io}';
protected $description = 'Check database, Redis, Elasticsearch, the public site and disk space, and report to healthchecks.io.';
public function handle(): int
{
if (! timebank_config('health_monitor.enabled', true)) {
$this->info('Health monitor is disabled (health_monitor.enabled=false).');
return self::SUCCESS;
}
$checks = [
'database' => fn () => $this->checkDatabase(),
'redis' => fn () => $this->checkRedis(),
'elasticsearch' => fn () => $this->checkElasticsearch(),
'website' => fn () => $this->checkWebsite(),
'disk' => fn () => $this->checkDisk(),
];
$failures = [];
foreach ($checks as $name => $check) {
try {
$problem = $check();
} catch (\Throwable $e) {
$problem = $e->getMessage();
}
if ($problem === null) {
$this->line("OK: {$name}");
} else {
$failures[] = "{$name}: {$problem}";
$this->error("FAIL: {$name}: {$problem}");
}
}
foreach ($failures as $line) {
Log::error('[health-monitor] ' . $line);
}
$this->report($failures);
return empty($failures) ? self::SUCCESS : self::FAILURE;
}
/** null when healthy, otherwise what is wrong. */
private function checkDatabase(): ?string
{
DB::select('SELECT 1');
return null;
}
private function checkRedis(): ?string
{
$reply = Redis::connection()->ping();
// phpredis answers true (or '+PONG'), predis a Status object.
return ($reply === true || stripos((string) $reply, 'PONG') !== false)
? null
: 'unexpected reply to PING: ' . var_export((string) $reply, true);
}
private function checkElasticsearch(): ?string
{
$host = config('elasticsearch.host');
if (! $host) {
// Not every installation runs search.
return null;
}
$url = (str_contains($host, '://') ? $host : 'http://' . $host) . '/_cluster/health';
$request = Http::timeout(10);
if (config('elasticsearch.user')) {
$request = $request->withBasicAuth(config('elasticsearch.user'), (string) config('elasticsearch.password'));
}
$response = $request->get($url);
if (! $response->successful()) {
return 'HTTP ' . $response->status();
}
// Yellow is normal on a single node (replicas cannot be placed); red
// means data is unavailable.
$status = $response->json('status');
return $status === 'red' ? 'cluster status red' : null;
}
private function checkWebsite(): ?string
{
$url = timebank_config('health_monitor.website_url') ?: config('app.url');
// Over the public URL on purpose: this goes through Apache, PHP, the
// database and the TLS certificate, as a visitor's request does.
$response = Http::timeout(20)->get($url);
return $response->status() === 200 ? null : "{$url} answered HTTP {$response->status()}";
}
private function checkDisk(): ?string
{
$path = base_path();
$total = @disk_total_space($path);
$free = @disk_free_space($path);
if (! $total || $free === false) {
return "cannot read disk space for {$path}";
}
$used = (int) round(100 * (1 - $free / $total));
$limit = (int) timebank_config('health_monitor.disk_max_percent', 90);
// Above 90% Elasticsearch also stops allocating shards.
return $used >= $limit ? "{$used}% used (limit {$limit}%)" : null;
}
/** Report to healthchecks.io: a ping when healthy, /fail with the reasons otherwise. */
private function report(array $failures): void
{
$pingUrl = timebank_config('health_monitor.healthcheck_url');
if (! $pingUrl) {
$this->comment('No health_monitor.healthcheck_url set - nothing reported.');
return;
}
if ($this->option('dry-run')) {
$this->comment('Dry run - not reported to healthchecks.io.');
return;
}
$url = rtrim($pingUrl, '/') . (empty($failures) ? '' : '/fail');
$body = empty($failures) ? 'All checks passed.' : implode("\n", $failures);
try {
// healthchecks.io shows the body in its alert, so the email says
// what failed.
Http::timeout(10)->withBody($body, 'text/plain')->post($url);
} catch (\Throwable $e) {
// Not reaching healthchecks.io ends as a missed ping, which it
// alerts on itself after the grace period.
Log::error('[health-monitor] Could not reach healthchecks.io: ' . $e->getMessage());
$this->error('Could not reach healthchecks.io: ' . $e->getMessage());
}
}
}