Lines 85.36% 35 / 41
Functions and Methods 71.42% 5 / 7
Classes and Traits 0.00% 0 / 1
Name Lines Functions and Methods CRAP Classes and Traits
AdminRequestHandler 85.36% 35 / 41 71.42% 5 / 7 28.12 0.00% 0 / 1
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 handleRequest 85.00% 17 / 20 0.00% 0 / 1 7.17
 answerRoute 100.00% 8 / 8 100.00% 1 / 1 8
 isAuthorized 100.00% 4 / 4 100.00% 1 / 1 3
 selectFailureStatus 100.00% 3 / 3 100.00% 1 / 1 4
 respondWithFailure 25.00% 1 / 4 0.00% 0 / 1 3.69
 respond 100.00% 1 / 1 100.00% 1 / 1 1
1<?php
2
3declare(strict_types=1);
4
5namespace Stewart\Runtime\Http\Admin;
6
7use Amp\Http\HttpStatus;
8use Amp\Http\Server\Request;
9use Amp\Http\Server\RequestHandler;
10use Amp\Http\Server\Response;
11use LogicException;
12use Psr\Log\LoggerInterface;
13use SensitiveParameter;
14use Stewart\Contracts\App\AppId;
15use Stewart\Contracts\Exception\ExceptionReason;
16use Stewart\Contracts\Exception\IdentifierError;
17use Stewart\Contracts\Exception\StewartException;
18use Stewart\Runtime\Admin\RuntimeAdmin;
19use Stewart\Runtime\Control\Protocol\Frame\CommandResult;
20use Stewart\Runtime\Control\Protocol\Frame\RequestFailed;
21use Stewart\Runtime\Exception\AppError;
22use Stewart\Runtime\Exception\WorkerError;
23use Stewart\Runtime\Http\Admin\Response\AdminAppList;
24use Stewart\Runtime\Http\Admin\Response\AdminAppView;
25use Stewart\Runtime\Lifecycle\AppPauseSource;
26use Stewart\Runtime\Metrics\PrometheusTextEncoder;
27use Stewart\Runtime\Metrics\RuntimeMetricsExporter;
28use Throwable;
29
30final readonly class AdminRequestHandler implements RequestHandler
31{
32    private const string BEARER_TOKEN = '~\ABearer +(\S+)\z~i';
33
34    private const array JSON_HEADERS = ['content-type' => 'application/json', 'cache-control' => 'no-store'];
35
36    private const array METRICS_HEADERS = ['content-type' => PrometheusTextEncoder::CONTENT_TYPE, 'cache-control' => 'no-store'];
37
38    public function __construct(
39        private RuntimeAdmin $admin,
40        private AdminApiCodec $codec,
41        private RuntimeMetricsExporter $metrics,
42        private LoggerInterface $logger,
43        #[SensitiveParameter]
44        private string $adminApiToken,
45    ) {}
46
47    public function handleRequest(Request $request): Response
48    {
49        if (!$this->isAuthorized($request)) {
50            return $this->respondWithFailure(HttpStatus::UNAUTHORIZED, new RequestFailed('unauthorized', 'A valid bearer token is required.'), ['www-authenticate' => 'Bearer']);
51        }
52
53        $path = $request->getUri()->getPath();
54        $route = AdminRoute::fromPath($path);
55
56        if ($route === null) {
57            return $this->respondWithFailure(HttpStatus::NOT_FOUND, new RequestFailed('not_found', \sprintf('No admin endpoint at %s.', $path)));
58        }
59
60        if (!$route->allowsMethod($request->getMethod())) {
61            return $this->respondWithFailure(
62                HttpStatus::METHOD_NOT_ALLOWED,
63                new RequestFailed('method_not_allowed', \sprintf('%s does not accept %s.', $path, $request->getMethod())),
64                ['allow' => implode(', ', $route->action->listAllowedMethods())],
65            );
66        }
67
68        try {
69            return $route->action === AdminAction::ShowMetrics
70                ? new Response(HttpStatus::OK, self::METRICS_HEADERS, $this->metrics->exportMetrics())
71                : $this->respond(HttpStatus::OK, $this->answerRoute($route));
72        } catch (StewartException $e) {
73            return $this->respondWithFailure($this->selectFailureStatus($e->reason), new RequestFailed((string) $e->reason->value, $e->getMessage()));
74        } catch (Throwable $e) {
75            $this->logger->error('Could not answer the admin request', ['path' => $path, 'exception' => $e]);
76
77            return $this->respondWithFailure(HttpStatus::INTERNAL_SERVER_ERROR, new RequestFailed('internal_error', 'The admin request failed; the daemon log has the cause.'));
78        }
79    }
80
81    /** @throws StewartException */
82    private function answerRoute(AdminRoute $route): AdminAppList|AdminAppView|CommandResult
83    {
84        return match ($route->action) {
85            AdminAction::ListApps => new AdminAppList(array_map(AdminAppView::fromAppStatus(...), $this->admin->listHostedAppStatuses())),
86            AdminAction::ShowApp => AdminAppView::fromAppStatus($this->admin->requireAppStatus(new AppId((string) $route->resourceId))),
87            AdminAction::PauseApp => $this->admin->pauseApp(new AppId((string) $route->resourceId), AppPauseSource::Http),
88            AdminAction::ResumeApp => $this->admin->resumeApp(new AppId((string) $route->resourceId), AppPauseSource::Http),
89            AdminAction::ResetApp => $this->admin->resetApp(new AppId((string) $route->resourceId), AppPauseSource::Http),
90            AdminAction::RestartWorker => $this->admin->restartWorker((string) $route->resourceId),
91            AdminAction::ShowMetrics => throw new LogicException('Metrics are answered before the JSON routes.'),
92        };
93    }
94
95    private function isAuthorized(Request $request): bool
96    {
97        $authorization = $request->getHeader('authorization');
98
99        return $authorization !== null
100            && preg_match(self::BEARER_TOKEN, $authorization, $matches) === 1
101            && hash_equals($this->adminApiToken, $matches[1]);
102    }
103
104    private function selectFailureStatus(ExceptionReason $reason): int
105    {
106        return match ($reason) {
107            AppError::Unknown, IdentifierError::AppIdInvalid, IdentifierError::AppIdTooLong, WorkerError::Unknown => HttpStatus::NOT_FOUND,
108            AppError::Disabled, WorkerError::ShuttingDown => HttpStatus::CONFLICT,
109            default => HttpStatus::INTERNAL_SERVER_ERROR,
110        };
111    }
112
113    /** @param array<non-empty-string, string> $headers */
114    private function respondWithFailure(int $status, RequestFailed $failure, array $headers = []): Response
115    {
116        try {
117            return $this->respond($status, $failure, $headers);
118        } catch (Throwable $e) {
119            $this->logger->error('Could not encode the admin failure', ['exception' => $e]);
120
121            return new Response($status, $headers);
122        }
123    }
124
125    /**
126     * @param array<non-empty-string, string> $headers
127     * @throws Throwable
128     */
129    private function respond(int $status, AdminAppList|AdminAppView|CommandResult|RequestFailed $body, array $headers = []): Response
130    {
131        return new Response($status, [...self::JSON_HEADERS, ...$headers], $this->codec->encodeResponse($body));
132    }
133}