diff --git a/.env.example b/.env.example index dc35773..3ad00ae 100644 --- a/.env.example +++ b/.env.example @@ -73,6 +73,7 @@ MAIL_USERNAME=null MAIL_PASSWORD=null MAIL_FROM_ADDRESS="hello@example.com" MAIL_FROM_NAME="${APP_NAME}" +HEALTH_ALERT_EMAIL="health@example.com" SECURITY_ALERT_EMAIL="security@example.com" AWS_ACCESS_KEY_ID= diff --git a/.gitignore b/.gitignore index eb04617..a5acf99 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,4 @@ _ide_helper.php Homestead.json Homestead.yaml Thumbs.db +storage/framework/health_state.json diff --git a/bootstrap/app.php b/bootstrap/app.php index 99aef5f..f1dc390 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -17,6 +17,7 @@ $app = Application::configure(Env::get('APP_BASE_PATH', dirname(__DIR__))) ]) ->withMiddleware(require __DIR__ . '/middlewares.php') ->withExceptions(require __DIR__ . '/exceptions.php') + ->withSchedule(require __DIR__ . '/schedule.php') ->create(); $app->useAppPath($app->basePath('src/Presentation')); diff --git a/bootstrap/schedule.php b/bootstrap/schedule.php new file mode 100644 index 0000000..a3376b2 --- /dev/null +++ b/bootstrap/schedule.php @@ -0,0 +1,12 @@ +job(new HealthCheckJob()) + ->everyTenMinutes() + ->withoutOverlapping(15); +}; diff --git a/config/mail.php b/config/mail.php index 6b51b7f..877cf05 100644 --- a/config/mail.php +++ b/config/mail.php @@ -117,6 +117,17 @@ return [ 'name' => env('MAIL_FROM_NAME', env('APP_NAME', 'Laravel')), ], + /* + |-------------------------------------------------------------------------- + | Health Alerts Recipient + |-------------------------------------------------------------------------- + | + | The email address that receives administrative and critical health alerts. + | + */ + + 'health_recipient' => env('HEALTH_ALERT_EMAIL', 'health@example.com'), + /* |-------------------------------------------------------------------------- | Security Alerts Recipient diff --git a/src/Application/Health/Config/HealthAlertMailConfig.php b/src/Application/Health/Config/HealthAlertMailConfig.php new file mode 100644 index 0000000..214e23c --- /dev/null +++ b/src/Application/Health/Config/HealthAlertMailConfig.php @@ -0,0 +1,15 @@ +check(); + $stateFile = $this->stateFilePath ?? storage_path('framework/health_state.json'); + + $previousStatus = $this->readPreviousStatus($stateFile); + $currentStatus = $result->status; + + $this->saveCurrentState($stateFile, $result); + + if ($previousStatus === null) { + if (! $result->isHealthy()) { + $this->notifyDegraded($result, null, $mailer, $mailConfig, $logger); + } else { + $logger->info('Initial system health check completed: all components healthy.'); + } + + return; + } + + if ($currentStatus !== $previousStatus) { + if ($result->isHealthy()) { + $this->notifyRecovered($result, $previousStatus, $mailer, $mailConfig, $logger); + } else { + $this->notifyDegraded($result, $previousStatus, $mailer, $mailConfig, $logger); + } + } + } + + private function notifyDegraded( + HealthCheckResult $result, + ?HealthStatus $previousStatus, + Mailer $mailer, + HealthAlertMailConfig $mailConfig, + LoggerInterface $logger, + ): void { + $failedComponents = []; + $checksArray = $result->toArray()['checks']; + + foreach ($checksArray as $component => $data) { + if ($data['status'] === 'down') { + $failedComponents[] = sprintf('%s (%s)', $component, $data['message'] ?? 'down'); + } + } + + $reason = implode(', ', $failedComponents); + $logger->critical("System health check degraded: {$reason}", [ + 'status' => $result->status->value, + 'previousStatus' => $previousStatus?->value, + 'timestamp' => Utc::toRfc3339($result->timestamp), + 'checks' => $checksArray, + ]); + + report(new SystemHealthDegradedException("System health check degraded: {$reason}")); + + $mailable = new SystemHealthAlertMail( + status: $result->status, + occurredAt: $result->timestamp, + checks: $checksArray, + previousStatus: $previousStatus, + ); + + $mailer->to(new Address($mailConfig->recipientEmail->value))->send($mailable); + } + + private function notifyRecovered( + HealthCheckResult $result, + HealthStatus $previousStatus, + Mailer $mailer, + HealthAlertMailConfig $mailConfig, + LoggerInterface $logger, + ): void { + $checksArray = $result->toArray()['checks']; + + $logger->info('System health check recovered: all components are healthy.', [ + 'status' => $result->status->value, + 'previousStatus' => $previousStatus->value, + 'timestamp' => Utc::toRfc3339($result->timestamp), + ]); + + $mailable = new SystemHealthAlertMail( + status: $result->status, + occurredAt: $result->timestamp, + checks: $checksArray, + previousStatus: $previousStatus, + ); + + $mailer->to(new Address($mailConfig->recipientEmail->value))->send($mailable); + } + + private function readPreviousStatus(string $filePath): ?HealthStatus + { + if (! File::exists($filePath)) { + return null; + } + + try { + $content = (string) File::get($filePath); + /** @var array{status?: string}|null $data */ + $data = json_decode($content, true); + + if (is_array($data) && isset($data['status']) && is_string($data['status'])) { + return HealthStatus::tryFrom($data['status']); + } + } catch (\Throwable) { + return null; + } + + return null; + } + + private function saveCurrentState(string $filePath, HealthCheckResult $result): void + { + $dir = dirname($filePath); + if (! File::exists($dir)) { + File::makeDirectory($dir, 0755, true); + } + + $payload = [ + 'status' => $result->status->value, + 'timestamp' => Utc::toRfc3339($result->timestamp), + 'checks' => $result->toArray()['checks'], + ]; + + File::put($filePath, (string) json_encode($payload, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES)); + } +} diff --git a/src/Infra/Mail/SystemHealthAlertMail.php b/src/Infra/Mail/SystemHealthAlertMail.php new file mode 100644 index 0000000..882a043 --- /dev/null +++ b/src/Infra/Mail/SystemHealthAlertMail.php @@ -0,0 +1,54 @@ + $checks + */ + public function __construct( + public readonly HealthStatus $status, + public readonly \DateTimeInterface $occurredAt, + public readonly array $checks, + public readonly ?HealthStatus $previousStatus = null, + ) { + } + + public function envelope(): Envelope + { + $isRecovery = $this->status === HealthStatus::HEALTHY; + $subject = $isRecovery + ? '[Arcadia Health] 🟢 System Health Recovered- Component UP' + : '[Arcadia Health] 🛑 System Health Alert - Component DOWN'; + + return new Envelope(subject: $subject); + } + + public function content(): Content + { + return new Content( + markdown: 'mails.health-alert', + with: [ + 'status' => $this->status->value, + 'isRecovery' => $this->status === HealthStatus::HEALTHY, + 'occurredAt' => $this->occurredAt, + 'previousStatus' => $this->previousStatus?->value, + 'checksJson' => (string) json_encode($this->checks, \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES), + ], + ); + } +} diff --git a/src/Infra/Providers/ConfigServiceProvider.php b/src/Infra/Providers/ConfigServiceProvider.php index f073531..735c4cc 100644 --- a/src/Infra/Providers/ConfigServiceProvider.php +++ b/src/Infra/Providers/ConfigServiceProvider.php @@ -6,6 +6,7 @@ namespace Infra\Providers; use Application\Arcadia\Config\AppConfig; use Application\Arcadia\Config\ThrottlingConfig; +use Application\Health\Config\HealthAlertMailConfig; use Application\Security\Config\DevClientCredentials; use Application\Security\Config\HmacAuthConfig; use Application\Security\Config\SecurityAlertMailConfig; @@ -46,7 +47,13 @@ final class ConfigServiceProvider extends ServiceProvider $this->app->bind(SecurityAlertMailConfig::class, static function (): SecurityAlertMailConfig { return new SecurityAlertMailConfig( - recipientEmail: new Email(Config::string('mail.security_recipient', 'security@example.com')), + recipientEmail: new Email(Config::string('mail.security_recipient')), + ); + }); + + $this->app->bind(HealthAlertMailConfig::class, static function (): HealthAlertMailConfig { + return new HealthAlertMailConfig( + recipientEmail: new Email(Config::string('mail.health_recipient')) ); }); diff --git a/src/Presentation/Console/Commands/HealthCheckCommand.php b/src/Presentation/Console/Commands/HealthCheckCommand.php new file mode 100644 index 0000000..8d74ba8 --- /dev/null +++ b/src/Presentation/Console/Commands/HealthCheckCommand.php @@ -0,0 +1,87 @@ +healthChecker->check(); + + if ($this->option('json')) { + $this->line((string) json_encode( + $result->toArray(), + \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES + )); + + return $result->isHealthy() ? self::SUCCESS : self::FAILURE; + } + + $this->renderTable($result); + + if (! $result->isHealthy()) { + $this->error('Health check failed. One or more components are unhealthy.'); + + return self::FAILURE; + } + + $this->info('Health check passed. All components are healthy.'); + + return self::SUCCESS; + } + + private function renderTable(HealthCheckResult $result): void + { + $rows = [ + [ + 'Default Database Connectivity', + $result->checks->databaseDefault->status->value, + $result->checks->databaseDefault->connection . ($result->checks->databaseDefault->message !== null ? + " ({$result->checks->databaseDefault->message})" : ''), + ], + [ + 'Arcadia Database Connectivity', + $result->checks->databaseArcadia->status->value, + $result->checks->databaseArcadia->connection . ($result->checks->databaseArcadia->message !== null ? + " ({$result->checks->databaseArcadia->message})" : ''), + ], + [ + 'Cache Store Connectivity', + $result->checks->cache->status->value, + $result->checks->cache->store . ($result->checks->cache->message !== null ? + " ({$result->checks->cache->message})" : ''), + ], + [ + 'Mail SMTP Connectivity', + $result->checks->mailSmtp->status->value, + $result->checks->mailSmtp->transport . ($result->checks->mailSmtp->message !== null ? + " ({$result->checks->mailSmtp->message})" : ''), + ], + ]; + + $this->table(['Component', 'Status', 'Details'], $rows); + } +} diff --git a/src/Presentation/Web/resources/views/mails/health-alert.blade.php b/src/Presentation/Web/resources/views/mails/health-alert.blade.php new file mode 100644 index 0000000..79372c4 --- /dev/null +++ b/src/Presentation/Web/resources/views/mails/health-alert.blade.php @@ -0,0 +1,26 @@ + + @if ($isRecovery) + # 🟢 Rétablissement Système - Arcadia API + + Tous les composants de l'infrastructure Arcadia API sont de nouveau opérationnels. + @else + # 🛑 Alerte Incident Système - Arcadia API + + Un ou plusieurs composants de l'infrastructure Arcadia API sont actuellement indisponibles. + @endif + + + **Statut Global :** {{ $isRecovery ? '🟢 Opérationnel (HEALTHY)' : '🔴 Dégradé (UNHEALTHY)' }} + @if ($previousStatus !== null) + **Statut Précédent :** `{{ $previousStatus }}` + @endif + **Date & Heure :** {{ $occurredAt->format('d/m/Y H:i:s') }} UTC + + **Détails des vérifications (JSON) :** + ```json + {!! $checksJson !!} + ``` + + + Ce message est généré automatiquement par le système de surveillance continue Arcadia API. + diff --git a/tests/Feature/Console/Commands/HealthCheckCommandTest.php b/tests/Feature/Console/Commands/HealthCheckCommandTest.php new file mode 100644 index 0000000..1286b09 --- /dev/null +++ b/tests/Feature/Console/Commands/HealthCheckCommandTest.php @@ -0,0 +1,77 @@ +artisan('health:check') + // THEN + ->expectsOutput('Health check passed. All components are healthy.') + ->assertSuccessful(); + }); + + it('returns failure when a component is unhealthy', function () use (&$originalDbDefault): void { + // GIVEN + /** @var \Tests\TestCase $this */ + Config::set('database.default', 'invalid_connection'); + + // WHEN + $this->artisan('health:check') + // THEN + ->expectsOutput('Health check failed. One or more components are unhealthy.') + ->assertFailed(); + }); + + it('outputs JSON when --json option is passed and succeeds when healthy', function (): void { + // GIVEN + /** @var \Tests\TestCase $this */ + + // WHEN + $this->artisan('health:check', ['--json' => true]) + // THEN + ->expectsOutputToContain('"status": "healthy"') + ->assertSuccessful(); + }); + + it('outputs JSON when --json option is passed and fails when unhealthy', function () use (&$originalArcadiaDb): void { + // GIVEN + /** @var \Tests\TestCase $this */ + Config::set('database.arcadia_meta_model', 'invalid_arcadia_connection'); + + // WHEN + $this->artisan('health:check', ['--json' => true]) + // THEN + ->expectsOutputToContain('"status": "unhealthy"') + ->assertFailed(); + }); +}); diff --git a/tests/Integration/Infra/Jobs/HealthCheckJobTest.php b/tests/Integration/Infra/Jobs/HealthCheckJobTest.php new file mode 100644 index 0000000..3444eed --- /dev/null +++ b/tests/Integration/Infra/Jobs/HealthCheckJobTest.php @@ -0,0 +1,222 @@ +shouldReceive('check')->once()->andReturn($healthyResult); + + // WHEN + $job->handle( + $healthChecker, + app(Mailer::class), + app(HealthAlertMailConfig::class), + app(LoggerInterface::class), + ); + + // THEN + expect(File::exists($tempStateFile))->toBeTrue(); + $saved = json_decode((string) File::get($tempStateFile), true); + expect($saved['status'])->toBe('healthy'); + Mail::assertNothingQueued(); + }); + + it('sends degraded alert email on initial run when unhealthy', function () use (&$tempStateFile): void { + // GIVEN + $job = new HealthCheckJob($tempStateFile); + $healthChecker = mock(HealthChecker::class); + $unhealthyResult = new HealthCheckResult( + status: HealthStatus::UNHEALTHY, + timestamp: Utc::now(), + checks: new HealthChecks( + databaseDefault: new DatabaseCheckResult(ComponentStatus::DOWN, 'sqlite', 'Connection refused'), + databaseArcadia: new DatabaseCheckResult(ComponentStatus::UP, 'arcadia'), + cache: new CacheCheckResult(ComponentStatus::UP, 'file'), + mailSmtp: new MailCheckResult(ComponentStatus::UP, 'log'), + ), + ); + + $healthChecker->shouldReceive('check')->once()->andReturn($unhealthyResult); + + // WHEN + $job->handle( + $healthChecker, + app(Mailer::class), + app(HealthAlertMailConfig::class), + app(LoggerInterface::class), + ); + + // THEN + expect(File::exists($tempStateFile))->toBeTrue(); + $saved = json_decode((string) File::get($tempStateFile), true); + expect($saved['status'])->toBe('unhealthy'); + + Mail::assertQueued(SystemHealthAlertMail::class, static function (SystemHealthAlertMail $mail): bool { + return $mail->status === HealthStatus::UNHEALTHY + && $mail->previousStatus === null; + }); + }); + + it('sends alert when status transitions from healthy to unhealthy', function () use (&$tempStateFile): void { + // GIVEN: existing healthy state + File::put($tempStateFile, (string) json_encode([ + 'status' => 'healthy', + 'timestamp' => Utc::now()->format(\DateTimeInterface::RFC3339), + ])); + + $job = new HealthCheckJob($tempStateFile); + $healthChecker = mock(HealthChecker::class); + $unhealthyResult = new HealthCheckResult( + status: HealthStatus::UNHEALTHY, + timestamp: Utc::now(), + checks: new HealthChecks( + databaseDefault: new DatabaseCheckResult(ComponentStatus::UP, 'sqlite'), + databaseArcadia: new DatabaseCheckResult(ComponentStatus::DOWN, 'arcadia', 'DB down'), + cache: new CacheCheckResult(ComponentStatus::UP, 'file'), + mailSmtp: new MailCheckResult(ComponentStatus::UP, 'log'), + ), + ); + + $healthChecker->shouldReceive('check')->once()->andReturn($unhealthyResult); + + // WHEN + $job->handle( + $healthChecker, + app(Mailer::class), + app(HealthAlertMailConfig::class), + app(LoggerInterface::class), + ); + + // THEN + Mail::assertQueued(SystemHealthAlertMail::class, static function (SystemHealthAlertMail $mail): bool { + return $mail->status === HealthStatus::UNHEALTHY + && $mail->previousStatus === HealthStatus::HEALTHY; + }); + }); + + it('sends recovery alert when status transitions from unhealthy to healthy', function () use (&$tempStateFile): void { + // GIVEN: existing unhealthy state + File::put($tempStateFile, (string) json_encode([ + 'status' => 'unhealthy', + 'timestamp' => Utc::now()->format(\DateTimeInterface::RFC3339), + ])); + + $job = new HealthCheckJob($tempStateFile); + $healthChecker = mock(HealthChecker::class); + $healthyResult = new HealthCheckResult( + status: HealthStatus::HEALTHY, + timestamp: Utc::now(), + checks: new HealthChecks( + databaseDefault: new DatabaseCheckResult(ComponentStatus::UP, 'sqlite'), + databaseArcadia: new DatabaseCheckResult(ComponentStatus::UP, 'arcadia'), + cache: new CacheCheckResult(ComponentStatus::UP, 'file'), + mailSmtp: new MailCheckResult(ComponentStatus::UP, 'log'), + ), + ); + + $healthChecker->shouldReceive('check')->once()->andReturn($healthyResult); + + // WHEN + $job->handle( + $healthChecker, + app(Mailer::class), + app(HealthAlertMailConfig::class), + app(LoggerInterface::class), + ); + + // THEN + Mail::assertQueued(SystemHealthAlertMail::class, static function (SystemHealthAlertMail $mail): bool { + return $mail->status === HealthStatus::HEALTHY + && $mail->previousStatus === HealthStatus::UNHEALTHY; + }); + }); + + it('does not send email when status remains unchanged', function () use (&$tempStateFile): void { + // GIVEN: existing healthy state + File::put($tempStateFile, (string) json_encode([ + 'status' => 'healthy', + 'timestamp' => Utc::now()->format(\DateTimeInterface::RFC3339), + ])); + + $job = new HealthCheckJob($tempStateFile); + $healthChecker = mock(HealthChecker::class); + $healthyResult = new HealthCheckResult( + status: HealthStatus::HEALTHY, + timestamp: Utc::now(), + checks: new HealthChecks( + databaseDefault: new DatabaseCheckResult(ComponentStatus::UP, 'sqlite'), + databaseArcadia: new DatabaseCheckResult(ComponentStatus::UP, 'arcadia'), + cache: new CacheCheckResult(ComponentStatus::UP, 'file'), + mailSmtp: new MailCheckResult(ComponentStatus::UP, 'log'), + ), + ); + + $healthChecker->shouldReceive('check')->once()->andReturn($healthyResult); + + // WHEN + $job->handle( + $healthChecker, + app(Mailer::class), + app(HealthAlertMailConfig::class), + app(LoggerInterface::class), + ); + + // THEN + Mail::assertNothingQueued(); + }); +}); diff --git a/tests/Unit/Infra/Mail/SystemHealthAlertMailTest.php b/tests/Unit/Infra/Mail/SystemHealthAlertMailTest.php new file mode 100644 index 0000000..2e935ae --- /dev/null +++ b/tests/Unit/Infra/Mail/SystemHealthAlertMailTest.php @@ -0,0 +1,67 @@ + ['status' => 'down', 'connection' => 'mysql', 'message' => 'Connection refused'], + 'database_arcadia' => ['status' => 'up', 'connection' => 'arcadia'], + ]; + + // WHEN + $mail = new SystemHealthAlertMail( + status: HealthStatus::UNHEALTHY, + occurredAt: $occurredAt, + checks: $checks, + previousStatus: HealthStatus::HEALTHY, + ); + + $envelope = $mail->envelope(); + $content = $mail->content(); + + // THEN + expect($envelope->subject)->toBe('[Arcadia Health] 🛑 System Health Alert - Component DOWN') + ->and($content->markdown)->toBe('mails.health-alert') + ->and($content->with['status'])->toBe('unhealthy') + ->and($content->with['isRecovery'])->toBeFalse() + ->and($content->with['previousStatus'])->toBe('healthy') + ->and($content->with['checksJson'])->toContain('Connection refused'); + }); + + it('constructs recovery mail with recovery subject and json markdown content', function (): void { + // GIVEN + $occurredAt = new \DateTimeImmutable('2026-08-26 12:05:00 UTC'); + $checks = [ + 'database_default' => ['status' => 'up', 'connection' => 'mysql'], + 'database_arcadia' => ['status' => 'up', 'connection' => 'arcadia'], + ]; + + // WHEN + $mail = new SystemHealthAlertMail( + status: HealthStatus::HEALTHY, + occurredAt: $occurredAt, + checks: $checks, + previousStatus: HealthStatus::UNHEALTHY, + ); + + $envelope = $mail->envelope(); + $content = $mail->content(); + + // THEN + expect($envelope->subject)->toBe('[Arcadia Health] 🟢 System Health Recovered- Component UP') + ->and($content->markdown)->toBe('mails.health-alert') + ->and($content->with['status'])->toBe('healthy') + ->and($content->with['isRecovery'])->toBeTrue() + ->and($content->with['previousStatus'])->toBe('unhealthy'); + }); +});