Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add health checks infrastructure #29

Merged
merged 6 commits into from
Oct 23, 2024
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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
3.2.0
=====

* (feature) Add health checks (live + ready).
* (improvement) Bump Janus and update CI.
* (feature) Add default doctrine check integration.


3.1.0
=====

Expand Down
22 changes: 14 additions & 8 deletions composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,20 +19,23 @@
"21torr/bundle-helpers": "^2.2",
"21torr/cli": "^1.2.3",
"psr/log": "^3.0",
"symfony/cache": "^7.1",
"symfony/cache-contracts": "^3.5",
"symfony/clock": "^7.0",
"symfony/config": "^7.0",
"symfony/console": "^7.0",
"symfony/dependency-injection": "^7.0",
"symfony/clock": "^7.1",
"symfony/config": "^7.1",
"symfony/console": "^7.1",
"symfony/dependency-injection": "^7.1",
"symfony/event-dispatcher-contracts": "^3.5",
"symfony/filesystem": "^7.0",
"symfony/process": "^7.0"
"symfony/filesystem": "^7.1",
"symfony/framework-bundle": "^7.1",
"symfony/process": "^7.1"
},
"require-dev": {
"21torr/janus": "^1.3.4",
"21torr/janus": "^1.4",
"bamarni/composer-bin-plugin": "^1.8",
"doctrine/orm": "^3.0",
"roave/security-advisories": "dev-latest",
"symfony/phpunit-bridge": "^7.0"
"symfony/phpunit-bridge": "^7.1"
},
"replace": {
"symfony/polyfill-ctype": "*",
Expand All @@ -53,6 +56,9 @@
"symfony/polyfill-php82": "*",
"symfony/polyfill-php83": "*"
},
"suggest": {
"doctrine/orm": "For automatic integration of health checks"
},
"autoload": {
"psr-4": {
"Torr\\Hosting\\": "src/"
Expand Down
3 changes: 3 additions & 0 deletions config/routes.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
_import.hosting.health:
resource: routes/health.yaml
prefix: /health/
11 changes: 11 additions & 0 deletions config/routes/health.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
hosting.api.live:
path: /live
controller: Torr\Hosting\Controller\HealthApiController::healthCheckEndpoint
defaults:
type: live

hosting.api.ready:
path: /ready
controller: Torr\Hosting\Controller\HealthApiController::healthCheckEndpoint
defaults:
type: ready
2 changes: 0 additions & 2 deletions phpstan.neon
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
includes:
- vendor/21torr/janus/phpstan/lib.neon

# If you use simple-phpunit, you need to uncomment the following line.
# Always make sure to first run simple-phpunit and then PHPStan.
parameters:
bootstrapFiles:
- vendor/bin/.phpunit/phpunit/vendor/autoload.php
4 changes: 2 additions & 2 deletions src/BuildInfo/BuildInfoStorage.php
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ private function loadBuildInfo () : BuildInfo
catch (\JsonException $exception)
{
throw new InvalidBuildInfoException(
sprintf("Invalid build info JSON: %s", $exception->getMessage()),
\sprintf("Invalid build info JSON: %s", $exception->getMessage()),
previous: $exception,
);
}
Expand Down Expand Up @@ -98,7 +98,7 @@ public function refresh () : void
catch (\JsonException $exception)
{
throw new InvalidBuildInfoException(
sprintf("Failed to write build info JSON: %s", $exception->getMessage()),
\sprintf("Failed to write build info JSON: %s", $exception->getMessage()),
previous: $exception,
);
}
Expand Down
2 changes: 1 addition & 1 deletion src/Command/BuildHooksCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ protected function execute (InputInterface $input, OutputInterface $output) : in
// @todo remove code block + alias in v4
if ("hosting:hook:build" !== $input->getFirstArgument())
{
$message = sprintf(
$message = \sprintf(
"The command `%s` was deprecated. Use `hosting:hook:build` instead.",
$input->getFirstArgument(),
);
Expand Down
2 changes: 1 addition & 1 deletion src/Command/DeployHooksCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ protected function execute (InputInterface $input, OutputInterface $output) : in
// @todo remove code block + alias in v4
if ("hosting:hook:build" !== $input->getFirstArgument())
{
$message = sprintf(
$message = \sprintf(
"The command `%s` was deprecated. Use `hosting:hook:deploy` instead.",
$input->getFirstArgument(),
);
Expand Down
2 changes: 1 addition & 1 deletion src/Command/ShowBuildInfoCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ protected function execute (InputInterface $input, OutputInterface $output) : in
foreach ($info->getAll() as $key => $value)
{
$rows[] = [
sprintf("<fg=yellow>%s</>", $key),
\sprintf("<fg=yellow>%s</>", $key),
$this->formatValue($value),
];
}
Expand Down
64 changes: 64 additions & 0 deletions src/Controller/HealthApiController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<?php declare(strict_types=1);

namespace Torr\Hosting\Controller;

use Psr\Cache\CacheItemInterface;
use Psr\Clock\ClockInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
use Torr\Hosting\Event\HealthCheckLiveEvent;
use Torr\Hosting\Event\HealthCheckReadyEvent;

/**
* @final
*/
class HealthApiController extends AbstractController
{
private const string CACHE_KEY = "hosting.health_check.%s";

/**
*
*/
public function healthCheckEndpoint (
EventDispatcherInterface $dispatcher,
CacheInterface $cache,
ClockInterface $clock,
string $type,
) : JsonResponse
{
$event = match ($type)
{
"live" => new HealthCheckLiveEvent(),
"ready" => new HealthCheckReadyEvent(),
default => throw $this->createNotFoundException("Unknown health check type \"{$type}\""),
};

$failedCheck = $cache->get(
\sprintf(self::CACHE_KEY, $type),
static function (CacheItemInterface $item) use ($dispatcher, $event)
{
// cache for at most 60s
$item->expiresAfter(60);

return $dispatcher->dispatch($event)->getFailedCheck();
},
);

// check whether any check failed
if (null !== $failedCheck)
{
return $this->json([
"ok" => false,
"failed" => $failedCheck,
"checked" => $clock->now()->format("c"),
], 503);
}

return $this->json([
"ok" => true,
"checked" => $clock->now()->format("c"),
]);
}
}
2 changes: 1 addition & 1 deletion src/Deployment/TaskCli.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ final class TaskCli extends TorrStyle
*/
public function done (string $message) : void
{
$this->write(sprintf(
$this->write(\sprintf(
"<fg=green>✓</> %s",
$message,
));
Expand Down
46 changes: 46 additions & 0 deletions src/Event/HealthCheckLiveEvent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php declare(strict_types=1);

namespace Torr\Hosting\Event;

use Symfony\Contracts\EventDispatcher\Event;

/**
* Event to add health checks for your application.
*
* This implements the "live" event, so the container should/can be restarted, if this event fails.
*
* Please note, that you should only mark something as "failed" if it is a problem that can potentially
* be fixed via a restart of the container. So you should not check permanent config errors or
* similar things here.
*
* @final
*/
class HealthCheckLiveEvent extends Event
{
private ?string $failedCheck = null;

/**
*
*/
public function markAsFailed (string $failedCheck) : void
{
$this->failedCheck = $failedCheck;
$this->stopPropagation();
}

/**
*
*/
public function isHealthy () : bool
{
return null === $this->failedCheck;
}

/**
*
*/
public function getFailedCheck () : ?string
{
return $this->failedCheck;
}
}
38 changes: 38 additions & 0 deletions src/Event/HealthCheckReadyEvent.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<?php declare(strict_types=1);

namespace Torr\Hosting\Event;

use Symfony\Contracts\EventDispatcher\Event;

/**
* @final
*/
class HealthCheckReadyEvent extends Event
{
private ?string $failedCheck = null;

/**
*
*/
public function markAsFailed (string $failedCheck) : void
{
$this->failedCheck = $failedCheck;
$this->stopPropagation();
}

/**
*
*/
public function isHealthy () : bool
{
return null === $this->failedCheck;
}

/**
*
*/
public function getFailedCheck () : ?string
{
return $this->failedCheck;
}
}
44 changes: 44 additions & 0 deletions src/Listener/DoctrineHealthCheckListener.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php declare(strict_types=1);

namespace Torr\Hosting\Listener;

use Doctrine\ORM\EntityManagerInterface;
use Doctrine\ORM\Tools\SchemaValidator;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Torr\Hosting\Event\HealthCheckLiveEvent;

/**
* @final
*/
readonly class DoctrineHealthCheckListener
{
/**
*/
public function __construct (
private ?EntityManagerInterface $entityManager = null,
) {}

#[AsEventListener(priority: -100)]
public function onLiveCheck (HealthCheckLiveEvent $event) : void
{
if (null === $this->entityManager || !class_exists(SchemaValidator::class))
{
return;
}

$validator = new SchemaValidator($this->entityManager);
$result = $validator->validateMapping();

if (!empty($result))
{
$event->markAsFailed("doctrine: invalid mapping");

return;
}

if (!$validator->schemaInSyncWithMetadata())
{
$event->markAsFailed("doctrine: database out of sync");
}
}
}
Loading