diff --git a/src/ahriman/application/ahriman.py b/src/ahriman/application/ahriman.py index 7b3bd232..3fea5334 100644 --- a/src/ahriman/application/ahriman.py +++ b/src/ahriman/application/ahriman.py @@ -354,6 +354,7 @@ def _set_user_parser(root: SubParserAction) -> argparse.ArgumentParser: type=UserAccess, choices=UserAccess, default=UserAccess.Read) + parser.add_argument("--no-reload", help="do not reload authentication module", action="store_true") parser.add_argument("-p", "--password", help="user password") parser.add_argument("-r", "--remove", help="remove user from configuration", action="store_true") parser.set_defaults(handler=handlers.User, architecture=[""], lock=None, no_log=True, no_report=True, unsafe=True) diff --git a/src/ahriman/application/handlers/user.py b/src/ahriman/application/handlers/user.py index 25b19ee0..4b4d45fa 100644 --- a/src/ahriman/application/handlers/user.py +++ b/src/ahriman/application/handlers/user.py @@ -23,6 +23,7 @@ import getpass from pathlib import Path from typing import Type +from ahriman.application.application import Application from ahriman.application.handlers.handler import Handler from ahriman.core.configuration import Configuration from ahriman.models.user import User as MUser @@ -53,6 +54,10 @@ class User(Handler): User.create_configuration(auth_configuration, user, salt, args.as_service) User.write_configuration(configuration) + if not args.no_reload: + client = Application(architecture, configuration, no_report=False).repository.reporter + client.reload_auth() + @staticmethod def clear_user(configuration: Configuration, user: MUser) -> None: """ diff --git a/src/ahriman/core/configuration.py b/src/ahriman/core/configuration.py index a7195b3c..3828eee4 100644 --- a/src/ahriman/core/configuration.py +++ b/src/ahriman/core/configuration.py @@ -26,10 +26,13 @@ from logging.config import fileConfig from pathlib import Path from typing import Any, Dict, List, Optional, Type +from ahriman.core.exceptions import InitializeException + class Configuration(configparser.RawConfigParser): """ extension for built-in configuration parser + :ivar architecture: repository architecture :ivar path: path to root configuration file :cvar ARCHITECTURE_SPECIFIC_SECTIONS: known sections which can be architecture specific (required by dump) :cvar DEFAULT_LOG_FORMAT: default log format (in case of fallback) @@ -49,6 +52,7 @@ class Configuration(configparser.RawConfigParser): "list": lambda value: value.split(), "path": self.__convert_path, }) + self.architecture: Optional[str] = None self.path: Optional[Path] = None @property @@ -90,16 +94,16 @@ class Configuration(configparser.RawConfigParser): """ return f"{section}:{suffix}" - def __convert_path(self, parsed: str) -> Path: + def __convert_path(self, value: str) -> Path: """ convert string value to path object - :param parsed: string configuration value + :param value: string configuration value :return: path object which represents the configuration value """ - value = Path(parsed) - if self.path is None or value.is_absolute(): - return value - return self.path.parent / value + path = Path(value) + if self.path is None or path.is_absolute(): + return path + return self.path.parent / path def dump(self) -> Dict[str, Dict[str, str]]: """ @@ -165,6 +169,7 @@ class Configuration(configparser.RawConfigParser): merge architecture specific sections into main configuration :param architecture: repository architecture """ + self.architecture = architecture for section in self.ARCHITECTURE_SPECIFIC_SECTIONS: # get overrides specific = self.section_name(section, architecture) @@ -180,6 +185,15 @@ class Configuration(configparser.RawConfigParser): continue self.remove_section(foreign) + def reload(self) -> None: + """ + reload configuration if possible or raise exception otherwise + """ + if self.path is None or self.architecture is None: + raise InitializeException("Configuration path and/or architecture are not set") + self.load(self.path) + self.merge_sections(self.architecture) + def set_option(self, section: str, option: str, value: Optional[str]) -> None: """ set option. Unlike default `configparser.RawConfigParser.set` it also creates section if it does not exist diff --git a/src/ahriman/core/status/client.py b/src/ahriman/core/status/client.py index 0129a4b9..e605f257 100644 --- a/src/ahriman/core/status/client.py +++ b/src/ahriman/core/status/client.py @@ -77,6 +77,11 @@ class Client: """ return BuildStatus() + def reload_auth(self) -> None: + """ + reload authentication module call + """ + def remove(self, base: str) -> None: """ remove packages from watcher diff --git a/src/ahriman/core/status/web_client.py b/src/ahriman/core/status/web_client.py index 122d8579..5c75df7e 100644 --- a/src/ahriman/core/status/web_client.py +++ b/src/ahriman/core/status/web_client.py @@ -67,6 +67,13 @@ class WebClient(Client): """ return f"{self.address}/user-api/v1/login" + @property + def _reload_auth_url(self) -> str: + """ + :return: full url for web service to reload authentication module + """ + return f"{self.address}/status-api/v1/reload-auth" + @property def _status_url(self) -> str: """ @@ -191,6 +198,18 @@ class WebClient(Client): self.logger.exception("could not get service status") return BuildStatus() + def reload_auth(self) -> None: + """ + reload authentication module call + """ + try: + response = self.__session.post(self._reload_auth_url) + response.raise_for_status() + except requests.exceptions.HTTPError as e: + self.logger.exception("could not reload auth module: %s", exception_response_text(e)) + except Exception: + self.logger.exception("could not reload auth module") + def remove(self, base: str) -> None: """ remove packages from watcher diff --git a/src/ahriman/web/routes.py b/src/ahriman/web/routes.py index 64062bc4..cbdad29e 100644 --- a/src/ahriman/web/routes.py +++ b/src/ahriman/web/routes.py @@ -22,6 +22,7 @@ from pathlib import Path from ahriman.web.views.index import IndexView from ahriman.web.views.service.add import AddView +from ahriman.web.views.service.reload_auth import ReloadAuthView from ahriman.web.views.service.remove import RemoveView from ahriman.web.views.service.search import SearchView from ahriman.web.views.status.ahriman import AhrimanView @@ -43,12 +44,14 @@ def setup_routes(application: Application, static_path: Path) -> None: POST /service-api/v1/add add new packages to repository + POST /service-api/v1/reload-auth reload authentication module + POST /service-api/v1/remove remove existing package from repository - POST /service-api/v1/update update packages in repository, actually it is just alias for add - GET /service-api/v1/search search for substring in AUR + POST /service-api/v1/update update packages in repository, actually it is just alias for add + GET /status-api/v1/ahriman get current service status POST /status-api/v1/ahriman update service status @@ -75,6 +78,8 @@ def setup_routes(application: Application, static_path: Path) -> None: application.router.add_post("/service-api/v1/add", AddView) + application.router.add_post("/service-api/v1/reload-auth", ReloadAuthView) + application.router.add_post("/service-api/v1/remove", RemoveView) application.router.add_get("/service-api/v1/search", SearchView, allow_head=False) diff --git a/src/ahriman/web/views/base.py b/src/ahriman/web/views/base.py index 78818e0a..1d43694e 100644 --- a/src/ahriman/web/views/base.py +++ b/src/ahriman/web/views/base.py @@ -21,6 +21,7 @@ from aiohttp.web import View from typing import Any, Dict, List, Optional from ahriman.core.auth.auth import Auth +from ahriman.core.configuration import Configuration from ahriman.core.spawn import Spawn from ahriman.core.status.watcher import Watcher @@ -30,6 +31,14 @@ class BaseView(View): base web view to make things typed """ + @property + def configuration(self) -> Configuration: + """ + :return: configuration instance + """ + configuration: Configuration = self.request.app["configuration"] + return configuration + @property def service(self) -> Watcher: """ diff --git a/src/ahriman/web/views/service/reload_auth.py b/src/ahriman/web/views/service/reload_auth.py new file mode 100644 index 00000000..c0366cae --- /dev/null +++ b/src/ahriman/web/views/service/reload_auth.py @@ -0,0 +1,40 @@ +# +# Copyright (c) 2021 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 aiohttp.web_exceptions import HTTPNoContent + +from ahriman.core.auth.auth import Auth +from ahriman.web.views.base import BaseView + + +class ReloadAuthView(BaseView): + """ + reload authentication module web view + """ + + async def post(self) -> Response: + """ + reload authentication module. No parameters supported here + :return: 204 on success + """ + self.configuration.reload() + self.request.app["validator"] = Auth.load(self.configuration) + + return HTTPNoContent() diff --git a/tests/ahriman/application/handlers/test_handler_user.py b/tests/ahriman/application/handlers/test_handler_user.py index cc4576e2..699e0378 100644 --- a/tests/ahriman/application/handlers/test_handler_user.py +++ b/tests/ahriman/application/handlers/test_handler_user.py @@ -21,6 +21,7 @@ def _default_args(args: argparse.Namespace) -> argparse.Namespace: args.password = "pa55w0rd" args.access = UserAccess.Read args.as_service = False + args.no_reload = False args.remove = False return args @@ -30,11 +31,13 @@ def test_run(args: argparse.Namespace, configuration: Configuration, mocker: Moc must run command """ args = _default_args(args) + mocker.patch("pathlib.Path.mkdir") get_auth_configuration_mock = mocker.patch("ahriman.application.handlers.User.get_auth_configuration") create_configuration_mock = mocker.patch("ahriman.application.handlers.User.create_configuration") write_configuration_mock = mocker.patch("ahriman.application.handlers.User.write_configuration") create_user = mocker.patch("ahriman.application.handlers.User.create_user") get_salt_mock = mocker.patch("ahriman.application.handlers.User.get_salt") + reload_mock = mocker.patch("ahriman.core.status.client.Client.reload_auth") User.run(args, "x86_64", configuration, True) get_auth_configuration_mock.assert_called_once() @@ -42,6 +45,7 @@ def test_run(args: argparse.Namespace, configuration: Configuration, mocker: Moc create_user.assert_called_once() get_salt_mock.assert_called_once() write_configuration_mock.assert_called_once() + reload_mock.assert_called_once() def test_run_remove(args: argparse.Namespace, configuration: Configuration, mocker: MockerFixture) -> None: @@ -50,14 +54,32 @@ def test_run_remove(args: argparse.Namespace, configuration: Configuration, mock """ args = _default_args(args) args.remove = True + mocker.patch("pathlib.Path.mkdir") get_auth_configuration_mock = mocker.patch("ahriman.application.handlers.User.get_auth_configuration") create_configuration_mock = mocker.patch("ahriman.application.handlers.User.create_configuration") write_configuration_mock = mocker.patch("ahriman.application.handlers.User.write_configuration") + reload_mock = mocker.patch("ahriman.core.status.client.Client.reload_auth") User.run(args, "x86_64", configuration, True) get_auth_configuration_mock.assert_called_once() create_configuration_mock.assert_not_called() write_configuration_mock.assert_called_once() + reload_mock.assert_called_once() + + +def test_run_no_reload(args: argparse.Namespace, configuration: Configuration, mocker: MockerFixture) -> None: + """ + must run command with no reload + """ + args = _default_args(args) + args.no_reload = True + mocker.patch("ahriman.application.handlers.User.get_auth_configuration") + mocker.patch("ahriman.application.handlers.User.create_configuration") + mocker.patch("ahriman.application.handlers.User.write_configuration") + reload_mock = mocker.patch("ahriman.core.status.client.Client.reload_auth") + + User.run(args, "x86_64", configuration, True) + reload_mock.assert_not_called() def test_clear_user(configuration: Configuration, user: MUser) -> None: diff --git a/tests/ahriman/core/status/test_client.py b/tests/ahriman/core/status/test_client.py index a034a0fe..60e7f573 100644 --- a/tests/ahriman/core/status/test_client.py +++ b/tests/ahriman/core/status/test_client.py @@ -61,6 +61,13 @@ def test_get_self(client: Client) -> None: assert client.get_self().status == BuildStatusEnum.Unknown +def test_reload_auth(client: Client) -> None: + """ + must process auth reload without errors + """ + client.reload_auth() + + def test_remove(client: Client, package_ahriman: Package) -> None: """ must process remove without errors diff --git a/tests/ahriman/core/status/test_web_client.py b/tests/ahriman/core/status/test_web_client.py index c8348eab..22d1f5cb 100644 --- a/tests/ahriman/core/status/test_web_client.py +++ b/tests/ahriman/core/status/test_web_client.py @@ -230,6 +230,32 @@ def test_get_self_failed_http_error(web_client: WebClient, mocker: MockerFixture assert web_client.get_self().status == BuildStatusEnum.Unknown +def test_reload_auth(web_client: WebClient, mocker: MockerFixture) -> None: + """ + must process auth reload + """ + requests_mock = mocker.patch("requests.Session.post") + + web_client.reload_auth() + requests_mock.assert_called_with(pytest.helpers.anyvar(str, True)) + + +def test_reload_auth_failed(web_client: WebClient, mocker: MockerFixture) -> None: + """ + must suppress any exception happened during auth reload + """ + mocker.patch("requests.Session.post", side_effect=Exception()) + web_client.reload_auth() + + +def test_reload_auth_failed_http_error(web_client: WebClient, mocker: MockerFixture) -> None: + """ + must suppress any exception happened during removal + """ + mocker.patch("requests.Session.post", side_effect=requests.exceptions.HTTPError()) + web_client.reload_auth() + + def test_remove(web_client: WebClient, package_ahriman: Package, mocker: MockerFixture) -> None: """ must process package removal diff --git a/tests/ahriman/core/test_configuration.py b/tests/ahriman/core/test_configuration.py index 5392301d..de52a12a 100644 --- a/tests/ahriman/core/test_configuration.py +++ b/tests/ahriman/core/test_configuration.py @@ -5,6 +5,7 @@ import pytest from pytest_mock import MockerFixture from ahriman.core.configuration import Configuration +from ahriman.core.exceptions import InitializeException def test_from_path(mocker: MockerFixture) -> None: @@ -115,6 +116,7 @@ def test_getlist_single(configuration: Configuration) -> None: """ configuration.set_option("build", "test_list", "a") assert configuration.getlist("build", "test_list") == ["a"] + assert configuration.getlist("build", "test_list") == ["a"] def test_load_includes_missing(configuration: Configuration) -> None: @@ -170,6 +172,36 @@ def test_merge_sections_missing(configuration: Configuration) -> None: assert configuration.get("build", "key") == "value" +def test_reload(configuration: Configuration, mocker: MockerFixture) -> None: + """ + must reload configuration + """ + load_mock = mocker.patch("ahriman.core.configuration.Configuration.load") + merge_mock = mocker.patch("ahriman.core.configuration.Configuration.merge_sections") + + configuration.reload() + load_mock.assert_called_once() + merge_mock.assert_called_once() + + +def test_reload_no_architecture(configuration: Configuration) -> None: + """ + must raise exception on reload if no architecture set + """ + configuration.architecture = None + with pytest.raises(InitializeException): + configuration.reload() + + +def test_reload_no_path(configuration: Configuration) -> None: + """ + must raise exception on reload if no path set + """ + configuration.path = None + with pytest.raises(InitializeException): + configuration.reload() + + def test_set_option(configuration: Configuration) -> None: """ must set option correctly diff --git a/tests/ahriman/web/views/service/test_views_service_reload_auth.py b/tests/ahriman/web/views/service/test_views_service_reload_auth.py new file mode 100644 index 00000000..891bdd97 --- /dev/null +++ b/tests/ahriman/web/views/service/test_views_service_reload_auth.py @@ -0,0 +1,15 @@ +from aiohttp.test_utils import TestClient +from pytest_mock import MockerFixture + + +async def test_post(client: TestClient, mocker: MockerFixture) -> None: + """ + must call post request correctly + """ + reload_mock = mocker.patch("ahriman.core.configuration.Configuration.reload") + load_mock = mocker.patch("ahriman.core.auth.auth.Auth.load") + response = await client.post("/service-api/v1/reload-auth") + + assert response.ok + reload_mock.assert_called_once() + load_mock.assert_called_with(client.app["configuration"]) diff --git a/tests/ahriman/web/views/service/test_views_service_remove.py b/tests/ahriman/web/views/service/test_views_service_remove.py index c801c3be..df162079 100644 --- a/tests/ahriman/web/views/service/test_views_service_remove.py +++ b/tests/ahriman/web/views/service/test_views_service_remove.py @@ -6,19 +6,19 @@ async def test_post(client: TestClient, mocker: MockerFixture) -> None: """ must call post request correctly """ - add_mock = mocker.patch("ahriman.core.spawn.Spawn.packages_remove") + remove_mock = mocker.patch("ahriman.core.spawn.Spawn.packages_remove") response = await client.post("/service-api/v1/remove", json={"packages": ["ahriman"]}) assert response.ok - add_mock.assert_called_with(["ahriman"]) + remove_mock.assert_called_with(["ahriman"]) async def test_post_exception(client: TestClient, mocker: MockerFixture) -> None: """ must raise exception on missing packages payload """ - add_mock = mocker.patch("ahriman.core.spawn.Spawn.packages_remove") + remove_mock = mocker.patch("ahriman.core.spawn.Spawn.packages_remove") response = await client.post("/service-api/v1/remove") assert response.status == 400 - add_mock.assert_not_called() + remove_mock.assert_not_called() diff --git a/tests/ahriman/web/views/test_views_base.py b/tests/ahriman/web/views/test_views_base.py index e0ff3117..907ef776 100644 --- a/tests/ahriman/web/views/test_views_base.py +++ b/tests/ahriman/web/views/test_views_base.py @@ -5,6 +5,13 @@ from multidict import MultiDict from ahriman.web.views.base import BaseView +def test_configuration(base: BaseView) -> None: + """ + must return configuration + """ + assert base.configuration + + def test_service(base: BaseView) -> None: """ must return service