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()