Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions app/Checks/DatabaseAvailability.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

declare(strict_types=1);

namespace App\Checks;

/**
* Health checks that need a database ask here first. Apps without a
* database set `health.uses_database` to false in config/health.php; their
* database checks then report ok ("no database") instead of failing, so
* every app registers the same list of checks.
*/
final class DatabaseAvailability
{
public static function isAvailable(): bool
{
return config('health.uses_database', true) !== false;
}
}
23 changes: 23 additions & 0 deletions app/Checks/DatabaseCheck.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

declare(strict_types=1);

namespace App\Checks;

use Spatie\Health\Checks\Checks\DatabaseCheck as SpatieDatabaseCheck;
use Spatie\Health\Checks\Result;

/**
* spatie's DatabaseCheck, reporting ok for apps without a database.
*/
final class DatabaseCheck extends SpatieDatabaseCheck
{
public function run(): Result
{
if (! DatabaseAvailability::isAvailable()) {
return Result::make()->shortSummary('no database')->ok();
}

return parent::run();
}
}
35 changes: 35 additions & 0 deletions app/Checks/DatabaseConnectionCountCheck.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

declare(strict_types=1);

namespace App\Checks;

use Spatie\Health\Checks\Checks\DatabaseConnectionCountCheck as SpatieDatabaseConnectionCountCheck;
use Spatie\Health\Checks\Result;

/**
* spatie's DatabaseConnectionCountCheck with the agreed thresholds (warn
* above 50, fail above 80), reporting ok for apps without a database and
* for SQLite, which has no connection count.
*/
class DatabaseConnectionCountCheck extends SpatieDatabaseConnectionCountCheck
{
protected ?int $warningThreshold = 50;

protected int $errorThreshold = 80;

public function run(): Result
{
if (! DatabaseAvailability::isAvailable()) {
return Result::make()->shortSummary('no database')->ok();
}

$connection = $this->connectionName ?? $this->getDefaultConnectionName();

if (config("database.connections.{$connection}.driver") === 'sqlite') {
return Result::make()->shortSummary('sqlite')->ok();
}

return parent::run();
}
}
84 changes: 84 additions & 0 deletions app/Checks/EntraClientSecretCheck.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
<?php

declare(strict_types=1);

namespace App\Checks;

use Illuminate\Support\Facades\Http;
use Spatie\Health\Checks\Check;
use Spatie\Health\Checks\Result;
use Throwable;

/**
* Requests a client-credentials token from Microsoft Entra ID. Fails when
* the client secret is rejected: Entra secrets expire after at most 24
* months, and SSO then fails for every user at once. Reads the credentials
* from codebar-ag/laravel-microsoft-entra-sso or from socialite's
* services.microsoft, and reports ok when the app has no SSO configured.
*/
final class EntraClientSecretCheck extends Check
{
/** @var list<array{0: string, 1: string}> config key and tenant key, in lookup order */
private const array SOURCES = [['microsoft-entra-sso', 'tenant_id'], ['services.microsoft', 'tenant']];

/** Placeholder secrets some configs fall back to when SSO is not set up. */
private const array PLACEHOLDER_SECRETS = ['', 'not-configured'];

public function run(): Result
{
$result = Result::make();
$credentials = $this->credentials();

if ($credentials === null) {
return $result->shortSummary('no sso configured')->ok();
}

[$tenant, $clientId, $clientSecret] = $credentials;

try {
$response = Http::asForm()->timeout(3)->post("https://login.microsoftonline.com/{$tenant}/oauth2/v2.0/token", [
'grant_type' => 'client_credentials',
'client_id' => $clientId,
'client_secret' => $clientSecret,
'scope' => 'https://graph.microsoft.com/.default',
]);
} catch (Throwable $exception) {
return $result->shortSummary('entra unreachable')->failed("Entra request failed: {$exception->getMessage()}");
}

if ($response->successful()) {
return $result->shortSummary('secret valid')->ok();
}

$error = $response->json('error');
$error = is_string($error) ? $error : '';

return $result->shortSummary($error !== '' ? $error : "http {$response->status()}")
->failed('Entra rejected the client credentials ('.($error ?: $response->status()).'). Create a new client secret and update the environment.');
}

/**
* @return array{0: string, 1: string, 2: string}|null
*/
private function credentials(): ?array
{
foreach (self::SOURCES as [$configKey, $tenantKey]) {
$tenant = $this->configString("{$configKey}.{$tenantKey}");
$clientId = $this->configString("{$configKey}.client_id");
$clientSecret = $this->configString("{$configKey}.client_secret");

if ($tenant !== '' && $clientId !== '' && ! in_array($clientSecret, self::PLACEHOLDER_SECRETS, true)) {
return [$tenant, $clientId, $clientSecret];
}
}

return null;
}

private function configString(string $key): string
{
$value = config($key);

return is_string($value) ? $value : '';
}
}
30 changes: 30 additions & 0 deletions app/Checks/FailedJobsCheck.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

declare(strict_types=1);

namespace App\Checks;

use Illuminate\Support\Facades\DB;
use Spatie\Health\Checks\Check;
use Spatie\Health\Checks\Result;

class FailedJobsCheck extends Check
{
public function run(): Result
{
if (! DatabaseAvailability::isAvailable()) {
return Result::make()->shortSummary('no database')->ok();
}

$count = DB::table('failed_jobs')->count();

$result = Result::make();
$result->shortSummary("failed jobs table count: {$count}");

if ($count > 0) {
return $result->failed();
}

return $result->ok();
}
}
58 changes: 58 additions & 0 deletions app/Checks/JobsCheck.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
<?php

declare(strict_types=1);

namespace App\Checks;

use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Sleep;
use Spatie\Health\Checks\Check;
use Spatie\Health\Checks\Result;

class JobsCheck extends Check
{
/** Samples taken when the jobs table is non-empty (stability vs drain detection). */
public const int SAMPLE_COUNT = 5;

/** Pause between samples; keeps total wait ~1s instead of ~90s per run. */
public const int SAMPLE_INTERVAL_MILLISECONDS = 200;

public function run(): Result
{
if (! DatabaseAvailability::isAvailable()) {
return Result::make()->shortSummary('no database')->ok();
}

$count = $this->currentJobsTableCount();

$result = Result::make();
$result->shortSummary("jobs table count: {$count}");

if ($count <= 0) {
return $result->ok();
}

$newCounts = Collection::times(self::SAMPLE_COUNT, function () {
Sleep::for(self::SAMPLE_INTERVAL_MILLISECONDS)->milliseconds();

return $this->currentJobsTableCount();
});

$allMatch = $newCounts->every(fn (int $value) => $value === $count);

$result->shortSummary('jobs table count: '.($allMatch ? 'stable' : 'changed'));

if (! $allMatch) {
return $result->ok();
}

return $result->failed();

}

protected function currentJobsTableCount(): int
{
return (int) DB::table('jobs')->count();
}
}
71 changes: 71 additions & 0 deletions app/Checks/PendingMigrationsCheck.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
<?php

declare(strict_types=1);

namespace App\Checks;

use Illuminate\Database\Migrations\Migrator;
use Spatie\Health\Checks\Check;
use Spatie\Health\Checks\Result;
use Throwable;

/**
* Fails when a migration file has not run against this database: the
* deploy command's `migrate --force` failed or was commented out, and the
* deployed code expects a schema the database does not have.
*/
final class PendingMigrationsCheck extends Check
{
public const int REPORTED_MIGRATIONS = 5;

public function run(): Result
{
if (! DatabaseAvailability::isAvailable()) {
return Result::make()->shortSummary('no database')->ok();
}

$result = Result::make();

try {
$pending = $this->pending();
} catch (Throwable $exception) {
return $result->shortSummary('unreadable')->failed("Cannot tell which migrations have run: {$exception->getMessage()}");
}

if ($pending === null) {
return $result->shortSummary('no migrations table')->failed('The migrations table does not exist, so this database has never been migrated.');
}

$result->shortSummary($pending === [] ? 'up to date' : count($pending).' pending');

if ($pending === []) {
return $result->ok();
}

$reported = array_slice($pending, 0, self::REPORTED_MIGRATIONS);
$hidden = count($pending) - count($reported);

return $result->failed(
count($pending).' migration(s) have not run: '.implode(', ', $reported)
.($hidden > 0 ? ", and {$hidden} more" : '')
.'. Run `php artisan migrate --force`.',
);
}

/**
* @return list<string>|null
*/
private function pending(): ?array
{
/** @var Migrator $migrator */
$migrator = app('migrator');

if (! $migrator->repositoryExists()) {
return null;
}

$files = $migrator->getMigrationFiles(array_merge([database_path('migrations')], $migrator->paths()));

return array_values(array_diff(array_keys($files), $migrator->getRepository()->getRan()));
}
}
53 changes: 53 additions & 0 deletions app/Checks/PostmarkTokenCheck.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<?php

declare(strict_types=1);

namespace App\Checks;

use Illuminate\Support\Facades\Http;
use Spatie\Health\Checks\Check;
use Spatie\Health\Checks\Result;
use Throwable;

/**
* Asks Postmark for the server that belongs to the configured server
* token. Fails when the token is missing or rejected, before mails pile up
* in failed jobs. Reports ok when the mailer is not postmark.
*/
final class PostmarkTokenCheck extends Check
{
public function run(): Result
{
$mailer = config('mail.default');

if ($mailer !== 'postmark') {
return Result::make()->shortSummary('mailer: '.(is_string($mailer) ? $mailer : 'none'))->ok();
}

$token = config('services.postmark.token') ?? config('services.postmark.key');
$token = is_string($token) ? $token : '';
$result = Result::make();

if ($token === '') {
return $result->shortSummary('no token')->failed('MAIL_MAILER is postmark, but no Postmark server token is configured.');
}

try {
$response = Http::withHeaders(['X-Postmark-Server-Token' => $token])
->acceptJson()
->timeout(3)
->get('https://api.postmarkapp.com/server');
} catch (Throwable $exception) {
return $result->shortSummary('postmark unreachable')->failed("Postmark request failed: {$exception->getMessage()}");
}

if ($response->successful()) {
return $result->shortSummary('token valid')->ok();
}

$message = $response->json('Message');

return $result->shortSummary("http {$response->status()}")
->failed('Postmark rejected the server token: '.(is_string($message) && $message !== '' ? $message : $response->status()).'.');
}
}
Loading
Loading