feat: add ability to disable specific routes (#119)

This commit is contained in:
Evgenii Alekseev 2023-12-15 14:34:03 +02:00 committed by GitHub
parent c54b14b833
commit e784032bc6
19 changed files with 128 additions and 21 deletions

View File

@ -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. * ``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. * ``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. * ``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. * ``static_path`` - path to directory with static files, string, required.
* ``templates`` - path to templates directories, space separated list of strings, 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. * ``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.

View File

@ -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. 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. Other settings are the same as mentioned above.
@ -1107,6 +1107,9 @@ Worker nodes (applicable for all workers) config (``worker.ini``) as:
manual = yes manual = yes
wait_timeout = 0 wait_timeout = 0
[web]
service_only = yes
[build] [build]
triggers = ahriman.core.upload.UploadTrigger ahriman.core.report.ReportTrigger triggers = ahriman.core.upload.UploadTrigger ahriman.core.report.ReportTrigger

View File

@ -342,6 +342,10 @@ CONFIGURATION_SCHEMA: ConfigurationSchema = {
"min": 0, "min": 0,
"max": 65535, "max": 65535,
}, },
"service_only": {
"type": "boolean",
"coerce": "boolean",
},
"static_path": { "static_path": {
"type": "path", "type": "path",
"coerce": "absolute_path", "coerce": "absolute_path",

View File

@ -25,18 +25,20 @@ from pkgutil import ModuleInfo, iter_modules
from types import ModuleType from types import ModuleType
from typing import Any, Type, TypeGuard from typing import Any, Type, TypeGuard
from ahriman.core.configuration import Configuration
from ahriman.web.views.base import BaseView from ahriman.web.views.base import BaseView
__all__ = ["setup_routes"] __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 extract dynamic routes based on views
Args: Args:
module_root(Path): root module path with views module_root(Path): root module path with views
configuration(Configuration): configuration instance
Returns: Returns:
dict[str, Type[View]]: map of the route to its view 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) view = getattr(module, attribute_name)
if not is_base_view(view): if not is_base_view(view):
continue 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 return routes
@ -101,16 +105,16 @@ def _modules(module_root: Path) -> Generator[ModuleInfo, None, None]:
yield module_info yield module_info
def setup_routes(application: Application, static_path: Path) -> None: def setup_routes(application: Application, configuration: Configuration) -> None:
""" """
setup all defined routes setup all defined routes
Args: Args:
application(Application): web application instance 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" views_root = Path(__file__).parent / "views"
for route, view in _dynamic_routes(views).items(): for route, view in _dynamic_routes(views_root, configuration).items():
application.router.add_view(route, view) application.router.add_view(route, view)

View File

@ -115,6 +115,21 @@ class BaseView(View, CorsViewMixin):
permission: UserAccess = getattr(cls, f"{method}_PERMISSION", UserAccess.Full) permission: UserAccess = getattr(cls, f"{method}_PERMISSION", UserAccess.Full)
return permission 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 @staticmethod
def get_non_empty(extractor: Callable[[str], T | None], key: str) -> T: def get_non_empty(extractor: Callable[[str], T | None], key: str) -> T:
""" """

View File

@ -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 <http://www.gnu.org/licenses/>.
#
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

View File

@ -26,9 +26,10 @@ from ahriman.models.changes import Changes
from ahriman.models.user_access import UserAccess from ahriman.models.user_access import UserAccess
from ahriman.web.schemas import AuthSchema, ChangesSchema, ErrorSchema, PackageNameSchema, RepositoryIdSchema from ahriman.web.schemas import AuthSchema, ChangesSchema, ErrorSchema, PackageNameSchema, RepositoryIdSchema
from ahriman.web.views.base import BaseView 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 package changes web view

View File

@ -28,9 +28,10 @@ from ahriman.models.user_access import UserAccess
from ahriman.web.schemas import AuthSchema, ErrorSchema, LogsSchema, PackageNameSchema, RepositoryIdSchema, \ from ahriman.web.schemas import AuthSchema, ErrorSchema, LogsSchema, PackageNameSchema, RepositoryIdSchema, \
VersionedLogSchema VersionedLogSchema
from ahriman.web.views.base import BaseView 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 package logs web view

View File

@ -28,9 +28,10 @@ from ahriman.models.user_access import UserAccess
from ahriman.web.schemas import AuthSchema, ErrorSchema, PackageNameSchema, PackageStatusSchema, \ from ahriman.web.schemas import AuthSchema, ErrorSchema, PackageNameSchema, PackageStatusSchema, \
PackageStatusSimplifiedSchema, RepositoryIdSchema PackageStatusSimplifiedSchema, RepositoryIdSchema
from ahriman.web.views.base import BaseView 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 package base specific web view

View File

@ -28,9 +28,10 @@ from ahriman.models.package import Package
from ahriman.models.user_access import UserAccess from ahriman.models.user_access import UserAccess
from ahriman.web.schemas import AuthSchema, ErrorSchema, PackageStatusSchema, PaginationSchema, RepositoryIdSchema from ahriman.web.schemas import AuthSchema, ErrorSchema, PackageStatusSchema, PaginationSchema, RepositoryIdSchema
from ahriman.web.views.base import BaseView 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 global watcher view

View File

@ -24,9 +24,10 @@ from aiohttp.web import HTTPNoContent, HTTPNotFound, Response, json_response
from ahriman.models.user_access import UserAccess from ahriman.models.user_access import UserAccess
from ahriman.web.schemas import AuthSchema, ErrorSchema, PatchNameSchema, PatchSchema from ahriman.web.schemas import AuthSchema, ErrorSchema, PatchNameSchema, PatchSchema
from ahriman.web.views.base import BaseView 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 package patch web view

View File

@ -25,9 +25,10 @@ from ahriman.models.pkgbuild_patch import PkgbuildPatch
from ahriman.models.user_access import UserAccess from ahriman.models.user_access import UserAccess
from ahriman.web.schemas import AuthSchema, ErrorSchema, PackageNameSchema, PatchSchema from ahriman.web.schemas import AuthSchema, ErrorSchema, PackageNameSchema, PatchSchema
from ahriman.web.views.base import BaseView 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 package patches web view

View File

@ -28,9 +28,10 @@ from ahriman.models.internal_status import InternalStatus
from ahriman.models.user_access import UserAccess from ahriman.models.user_access import UserAccess
from ahriman.web.schemas import AuthSchema, ErrorSchema, InternalStatusSchema, StatusSchema, RepositoryIdSchema from ahriman.web.schemas import AuthSchema, ErrorSchema, InternalStatusSchema, StatusSchema, RepositoryIdSchema
from ahriman.web.views.base import BaseView 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 web service status web view

View File

@ -24,9 +24,10 @@ from aiohttp.web import Response, json_response
from ahriman.models.user_access import UserAccess from ahriman.models.user_access import UserAccess
from ahriman.web.schemas import AuthSchema, ErrorSchema, LogSchema, PackageNameSchema, PaginationSchema from ahriman.web.schemas import AuthSchema, ErrorSchema, LogSchema, PackageNameSchema, PaginationSchema
from ahriman.web.views.base import BaseView 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 package logs web view

View File

@ -146,7 +146,7 @@ def setup_server(configuration: Configuration, spawner: Spawn, repositories: lis
application.middlewares.append(exception_handler(application.logger)) application.middlewares.append(exception_handler(application.logger))
application.logger.info("setup routes") application.logger.info("setup routes")
setup_routes(application, configuration.getpath("web", "static_path")) setup_routes(application, configuration)
application.logger.info("setup CORS") application.logger.info("setup CORS")
setup_cors(application) setup_cors(application)

View File

@ -18,6 +18,8 @@ def test_test_coverage() -> None:
elif (version := source_file.parts[4]) in ("v1", "v2"): elif (version := source_file.parts[4]) in ("v1", "v2"):
api = source_file.parts[5] api = source_file.parts[5]
filename = f"test_view_{version}_{api}_{source_file.name}" filename = f"test_view_{version}_{api}_{source_file.name}"
elif source_file.name.endswith("_guard.py"):
filename = f"test_{source_file.name}"
else: else:
filename = f"test_view_{source_file.name}" filename = f"test_view_{source_file.name}"
else: else:

View File

@ -11,7 +11,7 @@ from ahriman.core.util import walk
from ahriman.web.routes import _dynamic_routes, _module, _modules, setup_routes 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 must return all available routes
""" """
@ -19,10 +19,10 @@ def test_dynamic_routes(resource_path_root: Path) -> None:
expected_views = [ expected_views = [
file file
for file in walk(views_root) 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 all(isinstance(view, type) for view in routes.values())
assert len(set(routes.values())) == len(expected_views) 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 must generate non-empty list of routes
""" """
setup_routes(application, configuration.getpath("web", "static_path")) setup_routes(application, configuration)
assert application.router.routes() assert application.router.routes()

View File

@ -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) == []

View File

@ -6,6 +6,7 @@ from aiohttp.web import HTTPBadRequest, HTTPNotFound
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
from unittest.mock import AsyncMock from unittest.mock import AsyncMock
from ahriman.core.configuration import Configuration
from ahriman.models.repository_id import RepositoryId from ahriman.models.repository_id import RepositoryId
from ahriman.models.user_access import UserAccess from ahriman.models.user_access import UserAccess
from ahriman.web.views.base import BaseView 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 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: def test_get_non_empty() -> None:
""" """
must correctly extract non-empty values must correctly extract non-empty values