201 lines
7.1 KiB
PHP
201 lines
7.1 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Status;
|
|
|
|
use App\Models\Instance;
|
|
use App\Models\MonitoringTarget;
|
|
use App\Models\ProvisioningRun;
|
|
use Illuminate\Support\Carbon;
|
|
|
|
/**
|
|
* What the four public components are doing right now.
|
|
*
|
|
* Lifted out of StatusController so that the page and the sampler that writes
|
|
* the ninety-day history ask the same question in the same words. Two
|
|
* implementations of "is provisioning healthy" would disagree the first time
|
|
* one of them was changed, and the disagreement would show up as a green bar
|
|
* under a red banner.
|
|
*
|
|
* Every line is derived from a record. Where there is no signal a component
|
|
* says "not monitored" rather than "operational" — a status page that reports
|
|
* green because it has nothing to look at is worse than no status page,
|
|
* because someone will believe it.
|
|
*
|
|
* Aggregate only: counts, never customer names, instance addresses or host
|
|
* names. This is world-readable and the estate is not public information.
|
|
*/
|
|
class ServiceHealth
|
|
{
|
|
/** The components, in the order they appear. */
|
|
public const COMPONENTS = ['portal', 'instances', 'provisioning', 'backups'];
|
|
|
|
/** A backup older than this stops counting as current. */
|
|
private const BACKUP_STALE_AFTER_HOURS = 48;
|
|
|
|
/**
|
|
* How old a monitoring verdict may be before it stops counting.
|
|
*
|
|
* The sync job runs every five minutes; four missed runs is a monitoring
|
|
* pipeline that has stopped, and a verdict from then says nothing about now.
|
|
*/
|
|
private const MONITORING_STALE_AFTER_MINUTES = 20;
|
|
|
|
/** How far back a provisioning failure still says something about now. */
|
|
private const PROVISIONING_WINDOW_HOURS = 24;
|
|
|
|
/**
|
|
* @return array<int, array{key: string, state: string, detail: array<string, int>|null}>
|
|
*/
|
|
public function components(): array
|
|
{
|
|
return [
|
|
$this->portal(),
|
|
$this->instances(),
|
|
$this->provisioning(),
|
|
$this->backups(),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* The worst individual state is the state of the whole thing. Averaging it
|
|
* would let one outage disappear behind three healthy components.
|
|
*
|
|
* @param array<int, array{state: string}> $components
|
|
*/
|
|
public function overall(array $components): string
|
|
{
|
|
$states = array_column($components, 'state');
|
|
|
|
return match (true) {
|
|
in_array('down', $states, true) => 'down',
|
|
in_array('degraded', $states, true) => 'degraded',
|
|
in_array('unknown', $states, true) => 'unknown',
|
|
default => 'operational',
|
|
};
|
|
}
|
|
|
|
/**
|
|
* The portal and the website.
|
|
*
|
|
* Answering this request is the measurement. There is no honest way for a
|
|
* page to report that the server serving it is down.
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function portal(): array
|
|
{
|
|
return ['key' => 'portal', 'state' => 'operational', 'detail' => null];
|
|
}
|
|
|
|
/**
|
|
* Customer instances, from what monitoring last saw.
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function instances(): array
|
|
{
|
|
$total = Instance::query()->where('status', 'active')->count();
|
|
|
|
if ($total === 0) {
|
|
return ['key' => 'instances', 'state' => 'operational', 'detail' => null];
|
|
}
|
|
|
|
// Only targets belonging to instances that are actually in service. A
|
|
// healthy check on a decommissioned instance says nothing about a live
|
|
// one, and counting it would let coverage look complete when it is not.
|
|
$onActive = fn ($query) => $query->whereHas(
|
|
'instance',
|
|
fn ($instance) => $instance->where('status', 'active'),
|
|
);
|
|
|
|
// Only verdicts the sync job has actually refreshed. A row that has
|
|
// never been checked, or was last checked long enough ago that the
|
|
// answer means nothing, is not evidence of health — and this column was
|
|
// for a long time exactly that: written 'up' at provisioning and never
|
|
// touched again.
|
|
$fresh = Carbon::now()->subMinutes(self::MONITORING_STALE_AFTER_MINUTES);
|
|
$checked = fn ($query) => $query->tap($onActive)->where('checked_at', '>=', $fresh);
|
|
|
|
$watched = MonitoringTarget::query()->tap($checked)->distinct()->count('instance_id');
|
|
$down = MonitoringTarget::query()->tap($checked)->where('status', '!=', 'up')->distinct()->count('instance_id');
|
|
|
|
// Down first, then coverage. An instance nobody is watching is not a
|
|
// healthy instance — it is an unanswered question, and reporting the
|
|
// absence of a check as the absence of a problem is the one thing this
|
|
// page must never do.
|
|
return [
|
|
'key' => 'instances',
|
|
'state' => match (true) {
|
|
$down > 0 && $down >= $watched => 'down',
|
|
$down > 0 => 'degraded',
|
|
$watched < $total => 'unknown',
|
|
default => 'operational',
|
|
},
|
|
'detail' => match (true) {
|
|
$down > 0 => ['down' => $down, 'total' => $watched],
|
|
$watched < $total => ['down' => $total - $watched, 'total' => $total],
|
|
default => null,
|
|
},
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Whether new instances are being delivered.
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function provisioning(): array
|
|
{
|
|
$since = Carbon::now()->subHours(self::PROVISIONING_WINDOW_HOURS);
|
|
|
|
$failed = ProvisioningRun::query()
|
|
->where('status', ProvisioningRun::STATUS_FAILED)
|
|
->where('updated_at', '>=', $since)
|
|
->count();
|
|
|
|
return [
|
|
'key' => 'provisioning',
|
|
'state' => $failed === 0 ? 'operational' : 'degraded',
|
|
'detail' => $failed > 0 ? ['failed' => $failed] : null,
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Whether the last backup of every instance is recent.
|
|
*
|
|
* @return array<string, mixed>
|
|
*/
|
|
private function backups(): array
|
|
{
|
|
// Counted from the INSTANCES that need protecting, not from the backup
|
|
// rows that happen to exist. An active instance with no schedule at all
|
|
// has no row — so counting rows would leave it out of the arithmetic
|
|
// entirely and report the estate as protected because the backups that
|
|
// do exist are fine.
|
|
$total = Instance::query()->where('status', 'active')->count();
|
|
|
|
if ($total === 0) {
|
|
return ['key' => 'backups', 'state' => 'operational', 'detail' => null];
|
|
}
|
|
|
|
$fresh = Carbon::now()->subHours(self::BACKUP_STALE_AFTER_HOURS);
|
|
|
|
$protected = Instance::query()
|
|
->where('status', 'active')
|
|
->whereHas('backups', fn ($backup) => $backup->where('last_ok_at', '>=', $fresh))
|
|
->count();
|
|
|
|
$unprotected = $total - $protected;
|
|
|
|
return [
|
|
'key' => 'backups',
|
|
'state' => match (true) {
|
|
$unprotected === 0 => 'operational',
|
|
$unprotected >= $total => 'down',
|
|
default => 'degraded',
|
|
},
|
|
'detail' => $unprotected > 0 ? ['stale' => $unprotected, 'total' => $total] : null,
|
|
];
|
|
}
|
|
}
|