diff --git a/package/lib/systemd/system/ahriman-web.service b/package/lib/systemd/system/ahriman-web.service index eb7693d1..c7860529 100644 --- a/package/lib/systemd/system/ahriman-web.service +++ b/package/lib/systemd/system/ahriman-web.service @@ -5,6 +5,7 @@ After=network.target [Service] Type=simple ExecStart=/usr/bin/ahriman web +ExecReload=/usr/bin/ahriman web-reload User=ahriman Group=ahriman diff --git a/src/ahriman/application/handlers/reload.py b/src/ahriman/application/handlers/reload.py new file mode 100644 index 00000000..02c68e0d --- /dev/null +++ b/src/ahriman/application/handlers/reload.py @@ -0,0 +1,67 @@ +# +# 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 . +# +import argparse + +from ahriman.application.application import Application +from ahriman.application.handlers.handler import Handler, SubParserAction +from ahriman.core.configuration import Configuration +from ahriman.models.repository_id import RepositoryId + + +class Reload(Handler): + """ + web server handler + """ + + ALLOW_MULTI_ARCHITECTURE_RUN = False # system-wide action + + @classmethod + def run(cls, args: argparse.Namespace, repository_id: RepositoryId, configuration: Configuration, *, + report: bool) -> None: + """ + callback for command line + + Args: + args(argparse.Namespace): command line args + repository_id(RepositoryId): repository unique identifier + configuration(Configuration): configuration instance + report(bool): force enable or disable reporting + """ + application = Application(repository_id, configuration, report=True) + client = application.repository.reporter + client.configuration_reload() + + @staticmethod + def _set_reload_parser(root: SubParserAction) -> argparse.ArgumentParser: + """ + add parser for web reload subcommand + + Args: + root(SubParserAction): subparsers for the commands + + Returns: + argparse.ArgumentParser: created argument parser + """ + parser = root.add_parser("web-reload", help="reload configuration", + description="reload web server configuration") + parser.set_defaults(architecture="", lock=None, quiet=True, report=False, repository="", unsafe=True) + return parser + + arguments = [_set_reload_parser] diff --git a/src/ahriman/core/status/client.py b/src/ahriman/core/status/client.py index f74f5704..99913fb3 100644 --- a/src/ahriman/core/status/client.py +++ b/src/ahriman/core/status/client.py @@ -81,6 +81,11 @@ class Client: return make_local_client() + def configuration_reload(self) -> None: + """ + reload configuration + """ + def event_add(self, event: Event) -> None: """ create new event diff --git a/src/ahriman/core/status/web_client.py b/src/ahriman/core/status/web_client.py index 8c36503e..5de4beb6 100644 --- a/src/ahriman/core/status/web_client.py +++ b/src/ahriman/core/status/web_client.py @@ -17,6 +17,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . # +# pylint: disable=too-many-public-methods import contextlib from urllib.parse import quote_plus as url_encode @@ -165,6 +166,13 @@ class WebClient(Client, SyncAhrimanClient): """ return f"{self.address}/api/v1/status" + def configuration_reload(self) -> None: + """ + reload configuration + """ + with contextlib.suppress(Exception): + self.make_request("POST", f"{self.address}/api/v1/service/config") + def event_add(self, event: Event) -> None: """ create new event diff --git a/src/ahriman/web/schemas/__init__.py b/src/ahriman/web/schemas/__init__.py index 46117d58..5146bb22 100644 --- a/src/ahriman/web/schemas/__init__.py +++ b/src/ahriman/web/schemas/__init__.py @@ -22,6 +22,7 @@ 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 from ahriman.web.schemas.changes_schema import ChangesSchema +from ahriman.web.schemas.configuration_schema import ConfigurationSchema from ahriman.web.schemas.counters_schema import CountersSchema from ahriman.web.schemas.dependencies_schema import DependenciesSchema from ahriman.web.schemas.error_schema import ErrorSchema diff --git a/src/ahriman/web/schemas/configuration_schema.py b/src/ahriman/web/schemas/configuration_schema.py new file mode 100644 index 00000000..349a901c --- /dev/null +++ b/src/ahriman/web/schemas/configuration_schema.py @@ -0,0 +1,39 @@ +# +# 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, fields + + +class ConfigurationSchema(Schema): + """ + response configuration schema + """ + + key = fields.String(required=True, metadata={ + "description": "Configuration key", + "example": "host", + }) + section = fields.String(required=True, metadata={ + "description": "Configuration section", + "example": "web", + }) + value = fields.String(required=True, metadata={ + "description": "Configuration value", + "example": "127.0.0.1", + }) diff --git a/src/ahriman/web/views/v1/service/config.py b/src/ahriman/web/views/v1/service/config.py new file mode 100644 index 00000000..69396b06 --- /dev/null +++ b/src/ahriman/web/views/v1/service/config.py @@ -0,0 +1,84 @@ +# +# 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 HTTPNoContent, Response, json_response +from typing import ClassVar + +from ahriman.core.formatters import ConfigurationPrinter +from ahriman.models.user_access import UserAccess +from ahriman.web.apispec.decorators import apidocs +from ahriman.web.schemas import ConfigurationSchema +from ahriman.web.views.base import BaseView + + +class ConfigView(BaseView): + """ + configuration control view + + Attributes: + GET_PERMISSION(UserAccess): (class attribute) get permissions of self + POST_PERMISSION(UserAccess): (class attribute) post permissions of self + """ + + GET_PERMISSION = POST_PERMISSION = UserAccess.Full # type: ClassVar[UserAccess] + ROUTES = ["/api/v1/service/config"] + + @apidocs( + tags=["Actions"], + summary="Get configuration", + description="Get current web service configuration as nested dictionary", + permission=GET_PERMISSION, + schema=ConfigurationSchema(many=True), + ) + async def get(self) -> Response: + """ + get current web service configuration + + Returns: + Response: current web service configuration as nested dictionary + """ + dump = self.configuration.dump() + + response = [ + { + "section": section, + "key": key, + "value": value, + } for section, values in dump.items() + for key, value in values.items() + if key not in ConfigurationPrinter.HIDE_KEYS + ] + return json_response(response) + + @apidocs( + tags=["Actions"], + summary="Reload configuration", + description="Reload configuration from current files", + permission=POST_PERMISSION, + ) + async def post(self) -> None: + """ + reload web service configuration + + Raises: + HTTPNoContent: on success response + """ + self.configuration.reload() + + raise HTTPNoContent diff --git a/tests/ahriman/application/handlers/test_handler_reload.py b/tests/ahriman/application/handlers/test_handler_reload.py new file mode 100644 index 00000000..ae7c8b3d --- /dev/null +++ b/tests/ahriman/application/handlers/test_handler_reload.py @@ -0,0 +1,27 @@ +import argparse + +from pytest_mock import MockerFixture + +from ahriman.application.handlers.reload import Reload +from ahriman.core.configuration import Configuration +from ahriman.core.repository import Repository + + +def test_run(args: argparse.Namespace, configuration: Configuration, repository: Repository, + mocker: MockerFixture) -> None: + """ + must run command + """ + mocker.patch("ahriman.core.repository.Repository.load", return_value=repository) + run_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.configuration_reload") + + _, repository_id = configuration.check_loaded() + Reload.run(args, repository_id, configuration, report=False) + run_mock.assert_called_once_with() + + +def test_disallow_multi_architecture_run() -> None: + """ + must not allow multi architecture run + """ + assert not Reload.ALLOW_MULTI_ARCHITECTURE_RUN diff --git a/tests/ahriman/application/test_ahriman.py b/tests/ahriman/application/test_ahriman.py index 99634595..fbe1c596 100644 --- a/tests/ahriman/application/test_ahriman.py +++ b/tests/ahriman/application/test_ahriman.py @@ -1563,6 +1563,35 @@ def test_subparsers_web_option_repository(parser: argparse.ArgumentParser) -> No assert args.repository == "" +def test_subparsers_web_reload(parser: argparse.ArgumentParser) -> None: + """ + web-reload command must imply architecture, lock, quiet, report, repository and unsafe + """ + args = parser.parse_args(["web-reload"]) + assert args.architecture == "" + assert args.lock is None + assert args.quiet + assert not args.report + assert args.repository == "" + assert args.unsafe + + +def test_subparsers_web_reload_option_architecture(parser: argparse.ArgumentParser) -> None: + """ + web-reload command must correctly parse architecture list + """ + args = parser.parse_args(["-a", "x86_64", "web-reload"]) + assert args.architecture == "" + + +def test_subparsers_web_reload_option_repository(parser: argparse.ArgumentParser) -> None: + """ + web-reload command must correctly parse repository list + """ + args = parser.parse_args(["-r", "repo", "web-reload"]) + assert args.repository == "" + + def test_run(args: argparse.Namespace, configuration: Configuration, mocker: MockerFixture) -> None: """ application must be run diff --git a/tests/ahriman/core/status/test_client.py b/tests/ahriman/core/status/test_client.py index b79c499e..0ac2cf08 100644 --- a/tests/ahriman/core/status/test_client.py +++ b/tests/ahriman/core/status/test_client.py @@ -97,6 +97,13 @@ def test_load_web_client_from_legacy_unix_socket(configuration: Configuration, d assert isinstance(Client.load(repository_id, configuration, database, report=True), WebClient) +def test_configuration_reload(client: Client) -> None: + """ + must do nothing on configuration reload + """ + client.configuration_reload() + + def test_event_add(client: Client) -> None: """ must raise not implemented on event insertion diff --git a/tests/ahriman/core/status/test_web_client.py b/tests/ahriman/core/status/test_web_client.py index 087de9fb..b6165d80 100644 --- a/tests/ahriman/core/status/test_web_client.py +++ b/tests/ahriman/core/status/test_web_client.py @@ -107,6 +107,55 @@ def test_status_url(web_client: WebClient) -> None: assert web_client._status_url().endswith("/api/v1/status") +def test_configuration_reload(web_client: WebClient, mocker: MockerFixture) -> None: + """ + must reload configuration + """ + requests_mock = mocker.patch("ahriman.core.status.web_client.WebClient.make_request") + web_client.configuration_reload() + requests_mock.assert_called_once_with("POST", pytest.helpers.anyvar(str, True)) + + +def test_configuration_reload_failed(web_client: WebClient, mocker: MockerFixture) -> None: + """ + must suppress any exception happened during configuration reload + """ + mocker.patch("requests.Session.request", side_effect=Exception()) + web_client.configuration_reload() + + +def test_configuration_reload_failed_http_error(web_client: WebClient, mocker: MockerFixture) -> None: + """ + must suppress HTTP exception happened during configuration reload + """ + mocker.patch("requests.Session.request", side_effect=requests.HTTPError()) + web_client.configuration_reload() + + +def test_configuration_reload_failed_suppress(web_client: WebClient, mocker: MockerFixture) -> None: + """ + must suppress any exception happened during configuration reload and don't log + """ + web_client.suppress_errors = True + mocker.patch("requests.Session.request", side_effect=Exception()) + logging_mock = mocker.patch("logging.exception") + + web_client.configuration_reload() + logging_mock.assert_not_called() + + +def test_configuration_reload_failed_http_error_suppress(web_client: WebClient, mocker: MockerFixture) -> None: + """ + must suppress HTTP exception happened during configuration reload and don't log + """ + web_client.suppress_errors = True + mocker.patch("requests.Session.request", side_effect=requests.HTTPError()) + logging_mock = mocker.patch("logging.exception") + + web_client.configuration_reload() + logging_mock.assert_not_called() + + def test_event_add(web_client: WebClient, package_ahriman: Package, mocker: MockerFixture) -> None: """ must create event diff --git a/tests/ahriman/web/schemas/test_configuration_schema.py b/tests/ahriman/web/schemas/test_configuration_schema.py new file mode 100644 index 00000000..1982fb6b --- /dev/null +++ b/tests/ahriman/web/schemas/test_configuration_schema.py @@ -0,0 +1 @@ +# schema testing goes in view class tests diff --git a/tests/ahriman/web/views/v1/service/test_view_v1_service_config.py b/tests/ahriman/web/views/v1/service/test_view_v1_service_config.py new file mode 100644 index 00000000..008dbd99 --- /dev/null +++ b/tests/ahriman/web/views/v1/service/test_view_v1_service_config.py @@ -0,0 +1,54 @@ +import pytest + +from aiohttp.test_utils import TestClient +from pytest_mock import MockerFixture + +from ahriman.core.formatters.configuration_printer import ConfigurationPrinter +from ahriman.models.user_access import UserAccess +from ahriman.web.views.v1.service.config import ConfigView + + +async def test_get_permission() -> None: + """ + must return correct permission for the request + """ + for method in ("GET",): + request = pytest.helpers.request("", "", method) + assert await ConfigView.get_permission(request) == UserAccess.Full + for method in ("POST",): + request = pytest.helpers.request("", "", method) + assert await ConfigView.get_permission(request) == UserAccess.Full + + +def test_routes() -> None: + """ + must return correct routes + """ + assert ConfigView.ROUTES == ["/api/v1/service/config"] + + +async def test_get(client: TestClient) -> None: + """ + must get web configuration + """ + response_schema = pytest.helpers.schema_response(ConfigView.get) + + response = await client.get("/api/v1/service/config") + assert response.status == 200 + json = await response.json() + assert json # check that it is not empty + assert not response_schema.validate(json) + + # check that there are no keys which have to be hidden + assert not any(value["key"] in ConfigurationPrinter.HIDE_KEYS for value in json) + + +async def test_post(client: TestClient, mocker: MockerFixture) -> None: + """ + must update package changes + """ + reload_mock = mocker.patch("ahriman.core.configuration.Configuration.reload") + + response = await client.post("/api/v1/service/config") + assert response.status == 204 + reload_mock.assert_called_once_with()