diff --git a/README.md b/README.md index f5a97402c..6ac497db5 100644 --- a/README.md +++ b/README.md @@ -298,11 +298,15 @@ Some processes that are potentially blocking or long-running can be executed as The following example on how to run the background job processor uses [Supervisord](http://supervisord.org), but it is just as possible running it via any other process manager. - Copy [supervisor.conf](docs/supervisor.conf) to your supervisord configuration directory, modify it to your needs, and run it. +- Create an API key for the health checks (optional) and its hash (via `password_encode($password, PASSWORD_DEFAULT)`). - Enable background jobs by adding the following settings to your `config.json`: ```json { - "backgroundJobs": true + "backgroundJobs": { + "notifications": true + }, + "healthCheckKey": "$2y$12$...." } ``` diff --git a/components/BackgroundJobScheduler.php b/components/BackgroundJobScheduler.php index b130a7d83..7a39acfb9 100644 --- a/components/BackgroundJobScheduler.php +++ b/components/BackgroundJobScheduler.php @@ -9,9 +9,11 @@ class BackgroundJobScheduler { + public const HEALTH_MAX_AGE_SECONDS = 120; + public static function executeOrScheduleJob(IBackgroundJob $job): void { - if (AntragsgruenApp::getInstance()->backgroundJobs) { + if (isset(AntragsgruenApp::getInstance()->backgroundJobs['notifications']) && AntragsgruenApp::getInstance()->backgroundJobs['notifications']) { \Yii::$app->getDb()->createCommand( 'INSERT INTO `backgroundJob` (`siteId`, `consultationId`, `type`, `dateCreation`, `payload`) VALUES (:siteId, :consultationId, :type, NOW(), :payload)', [ @@ -25,4 +27,32 @@ public static function executeOrScheduleJob(IBackgroundJob $job): void $job->execute(); } } + + /** + * @return array{healthy: bool, data: array} + */ + public static function getDiagnostics(): array + { + $command = \Yii::$app->getDb()->createCommand('SELECT MIN(dateCreation) minAge, COUNT(*) num FROM backgroundJob WHERE dateStarted IS NULL'); + $result = $command->queryAll()[0]; + $unstarted = [ + 'num' => intval($result['num']), + 'age' => time() - Tools::dateSql2timestamp($result['minAge']), + ]; + + $command = \Yii::$app->getDb()->createCommand('SELECT MIN(dateCreation) minAge, COUNT(*) num FROM backgroundJob WHERE dateFinished IS NULL'); + $result = $command->queryAll()[0]; + $unfinished = [ + 'num' => intval($result['num']), + 'age' => time() - Tools::dateSql2timestamp($result['minAge']), + ]; + + return [ + 'healthy' => ($unstarted['age'] <= self::HEALTH_MAX_AGE_SECONDS && $unfinished['age'] <= self::HEALTH_MAX_AGE_SECONDS), + 'data' => [ + 'unstarted' => $unstarted, + 'unfinished' => $unfinished, + ], + ]; + } } diff --git a/config/urls.php b/config/urls.php index 5e8ff5712..c433c8bb8 100644 --- a/config/urls.php +++ b/config/urls.php @@ -81,9 +81,10 @@ $dom . 'page/' => 'pages/show-page', $dom . 'page//save' => 'pages/save-page', $dom . 'page//delete' => 'pages/delete-page', - $dom . 'admin/<_a:(siteconfig)>' => 'manager/<_a>', + $dom . 'admin/<_a:(siteconfig|health)>' => 'manager/<_a>', $restBase => 'consultation/rest-site', + $restBase . '/health' => '/manager/health', $restBaseCon => 'consultation/rest', $restBaseCon . '/proposed-procedure' => 'consultation/proposed-procedure-rest', $restBaseCon . '/motion/' => '/motion/rest', diff --git a/controllers/ManagerController.php b/controllers/ManagerController.php index 7496d34f4..25fa57f2b 100644 --- a/controllers/ManagerController.php +++ b/controllers/ManagerController.php @@ -1,25 +1,31 @@ id, ['siteconfig'])) { + if (in_array($action->id, [self::VIEW_ID_SITECONFIG, self::VIEW_ID_HEALTH])) { // No cookieValidationKey is set in the beginning RequestContext::getWebApplication()->request->enableCookieValidation = false; return parent::beforeAction($action); } - if (!$this->getParams()->multisiteMode && !in_array($action->id, ['siteconfig'])) { + if (!$this->getParams()->multisiteMode && !in_array($action->id, [self::VIEW_ID_SITECONFIG, self::VIEW_ID_HEALTH])) { return false; } @@ -118,4 +124,27 @@ public function actionSiteconfig(): ResponseInterface 'makeEditabeCommand' => $makeEditabeCommand, ])); } + + public function actionHealth(): RestApiResponse + { + $pwdHash = AntragsgruenApp::getInstance()->healthCheckKey; + if ($pwdHash === null) { + return new RestApiResponse(404, ['success' => false, 'error' => 'Health checks not activated']); + } + if ($this->getHttpHeader('X-API-Key') === null || !password_verify($this->getHttpHeader('X-API-Key'), $pwdHash)) { + return new RestApiResponse(401, ['success' => false, 'error' => 'No or invalid X-API-Key given']); + } + + $backgroundJobs = BackgroundJobScheduler::getDiagnostics(); + $healthy = $backgroundJobs['healthy']; + + return new RestApiResponse( + ($healthy ? 200 : 500), + [ + 'success' => true, + 'healthy' => $healthy, + 'backgroundJobs' => $backgroundJobs['data'], + ] + ); + } } diff --git a/models/settings/AntragsgruenApp.php b/models/settings/AntragsgruenApp.php index b92398e05..c046f5550 100644 --- a/models/settings/AntragsgruenApp.php +++ b/models/settings/AntragsgruenApp.php @@ -34,7 +34,6 @@ class AntragsgruenApp implements \JsonSerializable public bool $confirmEmailAddresses = true; public bool $enforceTwoFactorAuthentication = false; public bool $dataPrivacyCheckbox = false; - public bool $backgroundJobs = false; public string $mailFromName = 'Antragsgrün'; public string $mailFromEmail = ''; public ?string $mailDefaultReplyTo = null; @@ -59,6 +58,7 @@ class AntragsgruenApp implements \JsonSerializable public string $mode = 'production'; // [production | sandbox] public ?string $updateKey = null; public ?string $jwtPrivateKey = null; + public ?string $healthCheckKey = null; // A hash generated with password_hash(..., PASSWORD_DEFAULT) /** @var array{mode: string, ignoredIps: string[], difficulty: string} */ public array $captcha = [ @@ -78,6 +78,9 @@ class AntragsgruenApp implements \JsonSerializable /** @var array{installationId: string, wsUri: string, stompJsUri: string, rabbitMqUri: string, rabbitMqExchangeName: string, rabbitMqUsername: string, rabbitMqPassword: string}|null */ public ?array $live = null; + /** @var array{notifications?: bool}|null */ + public ?array $backgroundJobs = null; + public static function getInstance(): AntragsgruenApp { /** @var AntragsgruenApp $app */