diff --git a/docker/Dockerfile b/docker/Dockerfile index 7ca1debc..b7606a6f 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -40,6 +40,7 @@ RUN pacman -S --noconfirm --asdeps \ pacman -S --noconfirm --asdeps \ git \ python-aiohttp \ + python-aiohttp-openmetrics \ python-boto3 \ python-cerberus \ python-cryptography \ @@ -112,6 +113,7 @@ RUN pacman -S --noconfirm ahriman RUN pacman -S --noconfirm --asdeps \ python-aioauth-client \ python-aiohttp-apispec-git \ + python-aiohttp-openmetrics \ python-aiohttp-security \ python-aiohttp-session \ python-boto3 \ diff --git a/docs/ahriman.web.middlewares.rst b/docs/ahriman.web.middlewares.rst index 31725064..db4d1022 100644 --- a/docs/ahriman.web.middlewares.rst +++ b/docs/ahriman.web.middlewares.rst @@ -20,6 +20,14 @@ ahriman.web.middlewares.exception\_handler module :no-undoc-members: :show-inheritance: +ahriman.web.middlewares.metrics\_handler module +----------------------------------------------- + +.. automodule:: ahriman.web.middlewares.metrics_handler + :members: + :no-undoc-members: + :show-inheritance: + Module contents --------------- diff --git a/docs/ahriman.web.schemas.rst b/docs/ahriman.web.schemas.rst index d5349eb3..7295a7b8 100644 --- a/docs/ahriman.web.schemas.rst +++ b/docs/ahriman.web.schemas.rst @@ -4,6 +4,14 @@ ahriman.web.schemas package Submodules ---------- +ahriman.web.schemas.any\_schema module +-------------------------------------- + +.. automodule:: ahriman.web.schemas.any_schema + :members: + :no-undoc-members: + :show-inheritance: + ahriman.web.schemas.aur\_package\_schema module ----------------------------------------------- diff --git a/docs/ahriman.web.views.v1.status.rst b/docs/ahriman.web.views.v1.status.rst index 694f4159..99d60893 100644 --- a/docs/ahriman.web.views.v1.status.rst +++ b/docs/ahriman.web.views.v1.status.rst @@ -12,6 +12,14 @@ ahriman.web.views.v1.status.info module :no-undoc-members: :show-inheritance: +ahriman.web.views.v1.status.metrics module +------------------------------------------ + +.. automodule:: ahriman.web.views.v1.status.metrics + :members: + :no-undoc-members: + :show-inheritance: + ahriman.web.views.v1.status.repositories module ----------------------------------------------- diff --git a/package/archlinux/PKGBUILD b/package/archlinux/PKGBUILD index 8ba023ee..42bb1d63 100644 --- a/package/archlinux/PKGBUILD +++ b/package/archlinux/PKGBUILD @@ -75,6 +75,7 @@ package_ahriman-web() { depends=("$pkgbase-core=$pkgver" 'python-aiohttp-cors' 'python-aiohttp-jinja2') optdepends=('python-aioauth-client: OAuth2 authorization support' 'python-aiohttp-apispec>=3.0.0: autogenerated API documentation' + 'python-aiohttp-openmetrics: HTTP metrics support' 'python-aiohttp-security: authorization support' 'python-aiohttp-session: authorization support' 'python-cryptography: authorization support') diff --git a/pyproject.toml b/pyproject.toml index 91bf2970..6f359b2c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -69,6 +69,10 @@ web_auth = [ "aiohttp_security", "cryptography", ] +web_metrics = [ + "ahriman[web]", + "aiohttp-openmetrics", +] web_oauth2 = [ "ahriman[web_auth]", "aioauth-client", diff --git a/src/ahriman/web/middlewares/metrics_handler.py b/src/ahriman/web/middlewares/metrics_handler.py new file mode 100644 index 00000000..3cb26c56 --- /dev/null +++ b/src/ahriman/web/middlewares/metrics_handler.py @@ -0,0 +1,69 @@ +# +# Copyright (c) 2021-2025 ahriman team. +# +# This file is part of ahriman +# (see https://github.com/arcan1s/ahriman). +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +try: + import aiohttp_openmetrics +except ImportError: + aiohttp_openmetrics = None # type: ignore[assignment] + +from aiohttp.typedefs import Middleware +from aiohttp.web import HTTPNotFound, Request, Response, StreamResponse, middleware + +from ahriman.web.middlewares import HandlerType + + +__all__ = [ + "metrics", + "metrics_handler", +] + + +async def metrics(request: Request) -> Response: + """ + handler for returning metrics + + Args: + request(Request): request object + + Returns: + Response: response object + + Raises: + HTTPNotFound: endpoint is disabled + """ + if aiohttp_openmetrics is None: + raise HTTPNotFound + return await aiohttp_openmetrics.metrics(request) + + +def metrics_handler() -> Middleware: + """ + middleware for metrics support + + Returns: + Middleware: middleware function to handle server metrics + """ + if aiohttp_openmetrics is not None: + return aiohttp_openmetrics.metrics_middleware + + @middleware + async def handle(request: Request, handler: HandlerType) -> StreamResponse: + return await handler(request) + + return handle diff --git a/src/ahriman/web/routes.py b/src/ahriman/web/routes.py index 8be56b90..f2faa601 100644 --- a/src/ahriman/web/routes.py +++ b/src/ahriman/web/routes.py @@ -17,6 +17,8 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . # +import re + from aiohttp.web import Application, View from collections.abc import Generator @@ -45,6 +47,23 @@ def _dynamic_routes(configuration: Configuration) -> Generator[tuple[str, type[V yield route, view +def _identifier(route: str) -> str: + """ + extract valid route identifier (aka name) for the route. This method replaces curly brackets by single colon + and replaces other special symbols (including slashes) by underscore + + Args: + route(str): source route + + Returns: + str: route with special symbols being replaced + """ + # replace special symbols + alphanum = re.sub(r"[^A-Za-z\d\-{}]", "_", route) + # finally replace curly brackets + return alphanum.replace("{", ":").replace("}", "") + + def setup_routes(application: Application, configuration: Configuration) -> None: """ setup all defined routes @@ -53,7 +72,8 @@ def setup_routes(application: Application, configuration: Configuration) -> None application(Application): web application instance configuration(Configuration): configuration instance """ - application.router.add_static("/static", configuration.getpath("web", "static_path"), follow_symlinks=True) + application.router.add_static("/static", configuration.getpath("web", "static_path"), name="_static", + follow_symlinks=True) for route, view in _dynamic_routes(configuration): - application.router.add_view(route, view) + application.router.add_view(route, view, name=_identifier(route)) diff --git a/src/ahriman/web/schemas/__init__.py b/src/ahriman/web/schemas/__init__.py index 998ec790..dd440fb9 100644 --- a/src/ahriman/web/schemas/__init__.py +++ b/src/ahriman/web/schemas/__init__.py @@ -17,6 +17,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . # +from ahriman.web.schemas.any_schema import AnySchema from ahriman.web.schemas.aur_package_schema import AURPackageSchema from ahriman.web.schemas.auth_schema import AuthSchema from ahriman.web.schemas.build_options_schema import BuildOptionsSchema diff --git a/src/ahriman/web/schemas/any_schema.py b/src/ahriman/web/schemas/any_schema.py new file mode 100644 index 00000000..0129f03a --- /dev/null +++ b/src/ahriman/web/schemas/any_schema.py @@ -0,0 +1,26 @@ +# +# Copyright (c) 2021-2025 ahriman team. +# +# This file is part of ahriman +# (see https://github.com/arcan1s/ahriman). +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +from ahriman.web.apispec import Schema + + +class AnySchema(Schema): + """ + response dummy schema + """ diff --git a/src/ahriman/web/views/base.py b/src/ahriman/web/views/base.py index f5b4d9cf..77198cc4 100644 --- a/src/ahriman/web/views/base.py +++ b/src/ahriman/web/views/base.py @@ -167,6 +167,9 @@ class BaseView(View, CorsViewMixin): """ HEAD method implementation based on the result of GET method + Returns: + StreamResponse: generated response for the request + Raises: HTTPMethodNotAllowed: in case if there is no GET method implemented """ diff --git a/src/ahriman/web/views/v1/packages/packages.py b/src/ahriman/web/views/v1/packages/packages.py index 779591fe..818addec 100644 --- a/src/ahriman/web/views/v1/packages/packages.py +++ b/src/ahriman/web/views/v1/packages/packages.py @@ -46,7 +46,7 @@ class PackagesView(StatusViewGuard, BaseView): ROUTES = ["/api/v1/packages"] @apidocs( - tags=["packages"], + tags=["Packages"], summary="Get packages list", description="Retrieve packages and their descriptors", permission=GET_PERMISSION, diff --git a/src/ahriman/web/views/v1/status/metrics.py b/src/ahriman/web/views/v1/status/metrics.py new file mode 100644 index 00000000..c91f6bbd --- /dev/null +++ b/src/ahriman/web/views/v1/status/metrics.py @@ -0,0 +1,56 @@ +# +# Copyright (c) 2021-2025 ahriman team. +# +# This file is part of ahriman +# (see https://github.com/arcan1s/ahriman). +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +from aiohttp.web import Response +from typing import ClassVar + +from ahriman.models.user_access import UserAccess +from ahriman.web.apispec.decorators import apidocs +from ahriman.web.middlewares.metrics_handler import metrics +from ahriman.web.schemas import AnySchema +from ahriman.web.views.base import BaseView + + +class MetricsView(BaseView): + """ + open metrics endpoints + + Attributes: + GET_PERMISSION(UserAccess): (class attribute) get permissions of self + """ + + GET_PERMISSION: ClassVar[UserAccess] = UserAccess.Unauthorized + ROUTES = ["/api/v1/metrics"] + + @apidocs( + tags=["Status"], + summary="OpenMetrics endpoint", + description="Get service metrics in OpenMetrics format", + permission=GET_PERMISSION, + error_404_description="Endpoint is disabled", + schema=AnySchema, + ) + async def get(self) -> Response: + """ + get service HTTP metrics + + Returns: + Response: 200 with service metrics as generated by the library + """ + return await metrics(self.request) diff --git a/src/ahriman/web/web.py b/src/ahriman/web/web.py index aaa99cf8..547023d3 100644 --- a/src/ahriman/web/web.py +++ b/src/ahriman/web/web.py @@ -37,6 +37,7 @@ from ahriman.web.apispec.info import setup_apispec from ahriman.web.cors import setup_cors from ahriman.web.keys import AuthKey, ConfigurationKey, SpawnKey, WatcherKey, WorkersKey from ahriman.web.middlewares.exception_handler import exception_handler +from ahriman.web.middlewares.metrics_handler import metrics_handler from ahriman.web.routes import setup_routes @@ -146,6 +147,7 @@ def setup_server(configuration: Configuration, spawner: Spawn, repositories: lis application.middlewares.append(normalize_path_middleware(append_slash=False, remove_slash=True)) application.middlewares.append(exception_handler(application.logger)) + application.middlewares.append(metrics_handler()) application.logger.info("setup routes") setup_routes(application, configuration) diff --git a/tests/ahriman/web/middlewares/test_metrics_handler.py b/tests/ahriman/web/middlewares/test_metrics_handler.py new file mode 100644 index 00000000..8bff58a6 --- /dev/null +++ b/tests/ahriman/web/middlewares/test_metrics_handler.py @@ -0,0 +1,59 @@ +import importlib +import pytest +import sys + +from aiohttp.web import HTTPNotFound +from pytest_mock import MockerFixture +from unittest.mock import AsyncMock + +import ahriman.web.middlewares.metrics_handler as metrics_handler + + +async def test_metrics(mocker: MockerFixture) -> None: + """ + must return metrics methods if library is available + """ + metrics_mock = AsyncMock() + mocker.patch.object(metrics_handler, "aiohttp_openmetrics", metrics_mock) + + await metrics_handler.metrics(42) + metrics_mock.metrics.assert_called_once_with(42) + + +async def test_metrics_dummy(mocker: MockerFixture) -> None: + """ + must raise HTTPNotFound if no module found + """ + mocker.patch.object(metrics_handler, "aiohttp_openmetrics", None) + with pytest.raises(HTTPNotFound): + await metrics_handler.metrics(None) + + +async def test_metrics_handler() -> None: + """ + must return metrics handler if library is available + """ + assert metrics_handler.metrics_handler() == metrics_handler.aiohttp_openmetrics.metrics_middleware + + +async def test_metrics_handler_dummy(mocker: MockerFixture) -> None: + """ + must return dummy handler if no module found + """ + mocker.patch.object(metrics_handler, "aiohttp_openmetrics", None) + handler = metrics_handler.metrics_handler() + + async def handle(result: int) -> int: + return result + + assert await handler(42, handle) == 42 + + +def test_import_openmetrics_missing(mocker: MockerFixture) -> None: + """ + must correctly process missing module + """ + mocker.patch.dict(sys.modules, {"aiohttp_openmetrics": None}) + importlib.reload(metrics_handler) + + assert metrics_handler.aiohttp_openmetrics is None diff --git a/tests/ahriman/web/schemas/test_any_schema.py b/tests/ahriman/web/schemas/test_any_schema.py new file mode 100644 index 00000000..1982fb6b --- /dev/null +++ b/tests/ahriman/web/schemas/test_any_schema.py @@ -0,0 +1 @@ +# schema testing goes in view class tests diff --git a/tests/ahriman/web/test_routes.py b/tests/ahriman/web/test_routes.py index bba6bf5e..4a07a569 100644 --- a/tests/ahriman/web/test_routes.py +++ b/tests/ahriman/web/test_routes.py @@ -3,7 +3,7 @@ from pathlib import Path from ahriman.core.configuration import Configuration from ahriman.core.utils import walk -from ahriman.web.routes import _dynamic_routes, setup_routes +from ahriman.web.routes import _dynamic_routes, _identifier, setup_routes def test_dynamic_routes(resource_path_root: Path, configuration: Configuration) -> None: @@ -22,9 +22,19 @@ def test_dynamic_routes(resource_path_root: Path, configuration: Configuration) assert len(set(routes.values())) == len(expected_views) +def test_identifier() -> None: + """ + must correctly extract route identifiers + """ + assert _identifier("/") == "_" + assert _identifier("/api/v1/status") == "_api_v1_status" + assert _identifier("/api/v1/packages/{package}") == "_api_v1_packages_:package" + + def test_setup_routes(application: Application, configuration: Configuration) -> None: """ must generate non-empty list of routes """ + application.router._named_resources = {} setup_routes(application, configuration) assert application.router.routes() diff --git a/tests/ahriman/web/views/v1/status/test_view_v1_status_metrics.py b/tests/ahriman/web/views/v1/status/test_view_v1_status_metrics.py new file mode 100644 index 00000000..c2870cc6 --- /dev/null +++ b/tests/ahriman/web/views/v1/status/test_view_v1_status_metrics.py @@ -0,0 +1,50 @@ +import pytest + +from aiohttp.test_utils import TestClient +from aiohttp.web import Response +from pytest_mock import MockerFixture + +import ahriman.web.middlewares.metrics_handler as metrics_handler + +from ahriman.models.user_access import UserAccess +from ahriman.web.views.v1.status.metrics import MetricsView + + +async def test_get_permission() -> None: + """ + must return correct permission for the request + """ + for method in ("GET",): + request = pytest.helpers.request("", "", method) + assert await MetricsView.get_permission(request) == UserAccess.Unauthorized + + +def test_routes() -> None: + """ + must return correct routes + """ + assert MetricsView.ROUTES == ["/api/v1/metrics"] + + +async def test_get(client: TestClient, mocker: MockerFixture) -> None: + """ + must return service metrics + """ + metrics_mock = mocker.patch("ahriman.web.views.v1.status.metrics.metrics", return_value=Response()) + + response = await client.get("/api/v1/metrics") + assert response.ok + # there is no response validation here, because it is free text, so we check call instead + metrics_mock.assert_called_once_with(pytest.helpers.anyvar(int)) + + +async def test_get_not_found(client: TestClient, mocker: MockerFixture) -> None: + """ + must return 404 error if no module found + """ + mocker.patch.object(metrics_handler, "aiohttp_openmetrics", None) + response_schema = pytest.helpers.schema_response(MetricsView.get, code=404) + + response = await client.get("/api/v1/metrics") + assert response.status == 404 + assert not response_schema.validate(await response.json()) diff --git a/tox.ini b/tox.ini index b240f694..efe8369a 100644 --- a/tox.ini +++ b/tox.ini @@ -3,7 +3,7 @@ envlist = check, tests isolated_build = true labels = release = version, docs, publish -dependencies = -e .[journald,pacman,reports,s3,shell,stats,unixsocket,validator,web,web_api-docs,web_auth,web_oauth2] +dependencies = -e .[journald,pacman,reports,s3,shell,stats,unixsocket,validator,web,web_api-docs,web_auth,web_oauth2,web_metrics] project_name = ahriman [mypy]