From e784032bc6a35ba089a42ae5839b12b7cfa0c5ef Mon Sep 17 00:00:00 2001 From: Evgenii Alekseev Date: Fri, 15 Dec 2023 14:34:03 +0200 Subject: [PATCH] feat: add ability to disable specific routes (#119) --- docs/configuration.rst | 1 + docs/faq.rst | 5 ++- src/ahriman/core/configuration/schema.py | 4 ++ src/ahriman/web/routes.py | 18 ++++---- src/ahriman/web/views/base.py | 15 +++++++ src/ahriman/web/views/status_view_guard.py | 41 +++++++++++++++++++ src/ahriman/web/views/v1/status/changes.py | 3 +- src/ahriman/web/views/v1/status/logs.py | 3 +- src/ahriman/web/views/v1/status/package.py | 3 +- src/ahriman/web/views/v1/status/packages.py | 3 +- src/ahriman/web/views/v1/status/patch.py | 3 +- src/ahriman/web/views/v1/status/patches.py | 3 +- src/ahriman/web/views/v1/status/status.py | 3 +- src/ahriman/web/views/v2/status/logs.py | 3 +- src/ahriman/web/web.py | 2 +- tests/ahriman/test_tests.py | 2 + tests/ahriman/web/test_routes.py | 8 ++-- .../web/views/test_status_view_guard.py | 19 +++++++++ tests/ahriman/web/views/test_view_base.py | 10 +++++ 19 files changed, 128 insertions(+), 21 deletions(-) create mode 100644 src/ahriman/web/views/status_view_guard.py create mode 100644 tests/ahriman/web/views/test_status_view_guard.py diff --git a/docs/configuration.rst b/docs/configuration.rst index a3f41665..d4845610 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -129,6 +129,7 @@ Web server settings. If any of ``host``/``port`` is not set, web integration wil * ``index_url`` - full url of the repository index page, string, optional. * ``max_body_size`` - max body size in bytes to be validated for archive upload, integer, optional. If not set, validation will be disabled. * ``port`` - port to bind, integer, optional. +* ``service_only`` - disable status routes (including logs), boolean, optional, default ``no``. * ``static_path`` - path to directory with static files, string, required. * ``templates`` - path to templates directories, space separated list of strings, required. * ``unix_socket`` - path to the listening unix socket, string, optional. If set, server will create the socket on the specified address which can (and will) be used by application. Note, that unlike usual host/port configuration, unix socket allows to perform requests without authorization. diff --git a/docs/faq.rst b/docs/faq.rst index c4bf6595..cabe3b9e 100644 --- a/docs/faq.rst +++ b/docs/faq.rst @@ -1052,7 +1052,7 @@ It is required to point to the master node repository, otherwise internal depend Also, in case if authentication is enabled, the same user with the same password must be created for all workers. -It is also recommended to set ``web.wait_timeout`` to infinte in case of multiple conflicting runs. +It is also recommended to set ``web.wait_timeout`` to infinite in case of multiple conflicting runs and ``service_only`` to ``yes`` in order to disable status endpoints. Other settings are the same as mentioned above. @@ -1107,6 +1107,9 @@ Worker nodes (applicable for all workers) config (``worker.ini``) as: manual = yes wait_timeout = 0 + [web] + service_only = yes + [build] triggers = ahriman.core.upload.UploadTrigger ahriman.core.report.ReportTrigger diff --git a/src/ahriman/core/configuration/schema.py b/src/ahriman/core/configuration/schema.py index b6ed7fd6..9e414e70 100644 --- a/src/ahriman/core/configuration/schema.py +++ b/src/ahriman/core/configuration/schema.py @@ -342,6 +342,10 @@ CONFIGURATION_SCHEMA: ConfigurationSchema = { "min": 0, "max": 65535, }, + "service_only": { + "type": "boolean", + "coerce": "boolean", + }, "static_path": { "type": "path", "coerce": "absolute_path", diff --git a/src/ahriman/web/routes.py b/src/ahriman/web/routes.py index 9aacdfa5..15aa80b8 100644 --- a/src/ahriman/web/routes.py +++ b/src/ahriman/web/routes.py @@ -25,18 +25,20 @@ from pkgutil import ModuleInfo, iter_modules from types import ModuleType from typing import Any, Type, TypeGuard +from ahriman.core.configuration import Configuration from ahriman.web.views.base import BaseView __all__ = ["setup_routes"] -def _dynamic_routes(module_root: Path) -> dict[str, Type[View]]: +def _dynamic_routes(module_root: Path, configuration: Configuration) -> dict[str, Type[View]]: """ extract dynamic routes based on views Args: module_root(Path): root module path with views + configuration(Configuration): configuration instance Returns: dict[str, Type[View]]: map of the route to its view @@ -52,7 +54,9 @@ def _dynamic_routes(module_root: Path) -> dict[str, Type[View]]: view = getattr(module, attribute_name) if not is_base_view(view): continue - routes.update([(route, view) for route in view.ROUTES]) + + view_routes = view.routes(configuration) + routes.update([(route, view) for route in view_routes]) return routes @@ -101,16 +105,16 @@ def _modules(module_root: Path) -> Generator[ModuleInfo, None, None]: yield module_info -def setup_routes(application: Application, static_path: Path) -> None: +def setup_routes(application: Application, configuration: Configuration) -> None: """ setup all defined routes Args: application(Application): web application instance - static_path(Path): path to static files directory + configuration(Configuration): configuration instance """ - application.router.add_static("/static", static_path, follow_symlinks=True) + application.router.add_static("/static", configuration.getpath("web", "static_path"), follow_symlinks=True) - views = Path(__file__).parent / "views" - for route, view in _dynamic_routes(views).items(): + views_root = Path(__file__).parent / "views" + for route, view in _dynamic_routes(views_root, configuration).items(): application.router.add_view(route, view) diff --git a/src/ahriman/web/views/base.py b/src/ahriman/web/views/base.py index 356cec78..76d88cb0 100644 --- a/src/ahriman/web/views/base.py +++ b/src/ahriman/web/views/base.py @@ -115,6 +115,21 @@ class BaseView(View, CorsViewMixin): permission: UserAccess = getattr(cls, f"{method}_PERMISSION", UserAccess.Full) return permission + @classmethod + def routes(cls, configuration: Configuration) -> list[str]: + """ + extract routes list for the view + + Args: + configuration(Configuration): configuration instance + + Returns: + list[str]: list of routes defined for the view. By default, it tries to read :attr:`ROUTES` option if set + and returns empty list otherwise + """ + del configuration + return cls.ROUTES + @staticmethod def get_non_empty(extractor: Callable[[str], T | None], key: str) -> T: """ diff --git a/src/ahriman/web/views/status_view_guard.py b/src/ahriman/web/views/status_view_guard.py new file mode 100644 index 00000000..708803d1 --- /dev/null +++ b/src/ahriman/web/views/status_view_guard.py @@ -0,0 +1,41 @@ +# +# Copyright (c) 2021-2023 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.core.configuration import Configuration + + +class StatusViewGuard: + + ROUTES: list[str] + + @classmethod + def routes(cls, configuration: Configuration) -> list[str]: + """ + extract routes list for the view + + Args: + configuration(Configuration): configuration instance + + Returns: + list[str]: list of routes defined for the view. By default, it tries to read :attr:`ROUTES` option if set + and returns empty list otherwise + """ + if configuration.getboolean("web", "service_only", fallback=False): + return [] + return cls.ROUTES diff --git a/src/ahriman/web/views/v1/status/changes.py b/src/ahriman/web/views/v1/status/changes.py index 6313bf26..063d18b8 100644 --- a/src/ahriman/web/views/v1/status/changes.py +++ b/src/ahriman/web/views/v1/status/changes.py @@ -26,9 +26,10 @@ from ahriman.models.changes import Changes from ahriman.models.user_access import UserAccess from ahriman.web.schemas import AuthSchema, ChangesSchema, ErrorSchema, PackageNameSchema, RepositoryIdSchema from ahriman.web.views.base import BaseView +from ahriman.web.views.status_view_guard import StatusViewGuard -class ChangesView(BaseView): +class ChangesView(StatusViewGuard, BaseView): """ package changes web view diff --git a/src/ahriman/web/views/v1/status/logs.py b/src/ahriman/web/views/v1/status/logs.py index 73a0b690..fecdf4b3 100644 --- a/src/ahriman/web/views/v1/status/logs.py +++ b/src/ahriman/web/views/v1/status/logs.py @@ -28,9 +28,10 @@ from ahriman.models.user_access import UserAccess from ahriman.web.schemas import AuthSchema, ErrorSchema, LogsSchema, PackageNameSchema, RepositoryIdSchema, \ VersionedLogSchema from ahriman.web.views.base import BaseView +from ahriman.web.views.status_view_guard import StatusViewGuard -class LogsView(BaseView): +class LogsView(StatusViewGuard, BaseView): """ package logs web view diff --git a/src/ahriman/web/views/v1/status/package.py b/src/ahriman/web/views/v1/status/package.py index d82f582b..9a9b23e3 100644 --- a/src/ahriman/web/views/v1/status/package.py +++ b/src/ahriman/web/views/v1/status/package.py @@ -28,9 +28,10 @@ from ahriman.models.user_access import UserAccess from ahriman.web.schemas import AuthSchema, ErrorSchema, PackageNameSchema, PackageStatusSchema, \ PackageStatusSimplifiedSchema, RepositoryIdSchema from ahriman.web.views.base import BaseView +from ahriman.web.views.status_view_guard import StatusViewGuard -class PackageView(BaseView): +class PackageView(StatusViewGuard, BaseView): """ package base specific web view diff --git a/src/ahriman/web/views/v1/status/packages.py b/src/ahriman/web/views/v1/status/packages.py index c620846f..13215936 100644 --- a/src/ahriman/web/views/v1/status/packages.py +++ b/src/ahriman/web/views/v1/status/packages.py @@ -28,9 +28,10 @@ from ahriman.models.package import Package from ahriman.models.user_access import UserAccess from ahriman.web.schemas import AuthSchema, ErrorSchema, PackageStatusSchema, PaginationSchema, RepositoryIdSchema from ahriman.web.views.base import BaseView +from ahriman.web.views.status_view_guard import StatusViewGuard -class PackagesView(BaseView): +class PackagesView(StatusViewGuard, BaseView): """ global watcher view diff --git a/src/ahriman/web/views/v1/status/patch.py b/src/ahriman/web/views/v1/status/patch.py index ed3bc090..75f70cce 100644 --- a/src/ahriman/web/views/v1/status/patch.py +++ b/src/ahriman/web/views/v1/status/patch.py @@ -24,9 +24,10 @@ from aiohttp.web import HTTPNoContent, HTTPNotFound, Response, json_response from ahriman.models.user_access import UserAccess from ahriman.web.schemas import AuthSchema, ErrorSchema, PatchNameSchema, PatchSchema from ahriman.web.views.base import BaseView +from ahriman.web.views.status_view_guard import StatusViewGuard -class PatchView(BaseView): +class PatchView(StatusViewGuard, BaseView): """ package patch web view diff --git a/src/ahriman/web/views/v1/status/patches.py b/src/ahriman/web/views/v1/status/patches.py index 4892daf4..935a8e20 100644 --- a/src/ahriman/web/views/v1/status/patches.py +++ b/src/ahriman/web/views/v1/status/patches.py @@ -25,9 +25,10 @@ from ahriman.models.pkgbuild_patch import PkgbuildPatch from ahriman.models.user_access import UserAccess from ahriman.web.schemas import AuthSchema, ErrorSchema, PackageNameSchema, PatchSchema from ahriman.web.views.base import BaseView +from ahriman.web.views.status_view_guard import StatusViewGuard -class PatchesView(BaseView): +class PatchesView(StatusViewGuard, BaseView): """ package patches web view diff --git a/src/ahriman/web/views/v1/status/status.py b/src/ahriman/web/views/v1/status/status.py index d61655f1..965af421 100644 --- a/src/ahriman/web/views/v1/status/status.py +++ b/src/ahriman/web/views/v1/status/status.py @@ -28,9 +28,10 @@ from ahriman.models.internal_status import InternalStatus from ahriman.models.user_access import UserAccess from ahriman.web.schemas import AuthSchema, ErrorSchema, InternalStatusSchema, StatusSchema, RepositoryIdSchema from ahriman.web.views.base import BaseView +from ahriman.web.views.status_view_guard import StatusViewGuard -class StatusView(BaseView): +class StatusView(StatusViewGuard, BaseView): """ web service status web view diff --git a/src/ahriman/web/views/v2/status/logs.py b/src/ahriman/web/views/v2/status/logs.py index 37c5e90d..e383276a 100644 --- a/src/ahriman/web/views/v2/status/logs.py +++ b/src/ahriman/web/views/v2/status/logs.py @@ -24,9 +24,10 @@ from aiohttp.web import Response, json_response from ahriman.models.user_access import UserAccess from ahriman.web.schemas import AuthSchema, ErrorSchema, LogSchema, PackageNameSchema, PaginationSchema from ahriman.web.views.base import BaseView +from ahriman.web.views.status_view_guard import StatusViewGuard -class LogsView(BaseView): +class LogsView(StatusViewGuard, BaseView): """ package logs web view diff --git a/src/ahriman/web/web.py b/src/ahriman/web/web.py index 07ca0b84..1dd7362b 100644 --- a/src/ahriman/web/web.py +++ b/src/ahriman/web/web.py @@ -146,7 +146,7 @@ def setup_server(configuration: Configuration, spawner: Spawn, repositories: lis application.middlewares.append(exception_handler(application.logger)) application.logger.info("setup routes") - setup_routes(application, configuration.getpath("web", "static_path")) + setup_routes(application, configuration) application.logger.info("setup CORS") setup_cors(application) diff --git a/tests/ahriman/test_tests.py b/tests/ahriman/test_tests.py index e6c349ee..897f4ce7 100644 --- a/tests/ahriman/test_tests.py +++ b/tests/ahriman/test_tests.py @@ -18,6 +18,8 @@ def test_test_coverage() -> None: elif (version := source_file.parts[4]) in ("v1", "v2"): api = source_file.parts[5] filename = f"test_view_{version}_{api}_{source_file.name}" + elif source_file.name.endswith("_guard.py"): + filename = f"test_{source_file.name}" else: filename = f"test_view_{source_file.name}" else: diff --git a/tests/ahriman/web/test_routes.py b/tests/ahriman/web/test_routes.py index 12f54a88..10568591 100644 --- a/tests/ahriman/web/test_routes.py +++ b/tests/ahriman/web/test_routes.py @@ -11,7 +11,7 @@ from ahriman.core.util import walk from ahriman.web.routes import _dynamic_routes, _module, _modules, setup_routes -def test_dynamic_routes(resource_path_root: Path) -> None: +def test_dynamic_routes(resource_path_root: Path, configuration: Configuration) -> None: """ must return all available routes """ @@ -19,10 +19,10 @@ def test_dynamic_routes(resource_path_root: Path) -> None: expected_views = [ file for file in walk(views_root) - if file.suffix == ".py" and file.name not in ("__init__.py", "base.py") + if file.suffix == ".py" and file.name not in ("__init__.py", "base.py", "status_view_guard.py") ] - routes = _dynamic_routes(views_root) + routes = _dynamic_routes(views_root, configuration) assert all(isinstance(view, type) for view in routes.values()) assert len(set(routes.values())) == len(expected_views) @@ -74,5 +74,5 @@ def test_setup_routes(application: Application, configuration: Configuration) -> """ must generate non-empty list of routes """ - setup_routes(application, configuration.getpath("web", "static_path")) + setup_routes(application, configuration) assert application.router.routes() diff --git a/tests/ahriman/web/views/test_status_view_guard.py b/tests/ahriman/web/views/test_status_view_guard.py new file mode 100644 index 00000000..d2e0e730 --- /dev/null +++ b/tests/ahriman/web/views/test_status_view_guard.py @@ -0,0 +1,19 @@ +from ahriman.core.configuration import Configuration +from ahriman.web.views.status_view_guard import StatusViewGuard + + +def test_routes(configuration: Configuration) -> None: + """ + must correctly return routes list + """ + StatusViewGuard.ROUTES = routes = ["route1", "route2"] + assert StatusViewGuard.routes(configuration) == routes + + +def test_routes_empty(configuration: Configuration) -> None: + """ + must return empty routes list if option is set + """ + StatusViewGuard.ROUTES = ["route1", "route2"] + configuration.set_option("web", "service_only", "yes") + assert StatusViewGuard.routes(configuration) == [] diff --git a/tests/ahriman/web/views/test_view_base.py b/tests/ahriman/web/views/test_view_base.py index 64331a09..e01c5bdb 100644 --- a/tests/ahriman/web/views/test_view_base.py +++ b/tests/ahriman/web/views/test_view_base.py @@ -6,6 +6,7 @@ from aiohttp.web import HTTPBadRequest, HTTPNotFound from pytest_mock import MockerFixture from unittest.mock import AsyncMock +from ahriman.core.configuration import Configuration from ahriman.models.repository_id import RepositoryId from ahriman.models.user_access import UserAccess from ahriman.web.views.base import BaseView @@ -69,6 +70,15 @@ async def test_get_permission(base: BaseView) -> None: assert await base.get_permission(request) == UserAccess.Unauthorized +def test_get_routes(configuration: Configuration, mocker: MockerFixture) -> None: + """ + must return list of available routes + """ + routes = ["route1", "route2"] + mocker.patch.object(BaseView, "ROUTES", routes) + assert BaseView.routes(configuration) == routes + + def test_get_non_empty() -> None: """ must correctly extract non-empty values