mirror of
https://github.com/arcan1s/ahriman.git
synced 2025-07-12 05:25:49 +00:00
Compare commits
1 Commits
master
...
feature/we
Author | SHA1 | Date | |
---|---|---|---|
c9e52f1b8a |
@ -5,6 +5,7 @@ After=network.target
|
|||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
ExecStart=/usr/bin/ahriman web
|
ExecStart=/usr/bin/ahriman web
|
||||||
|
ExecReload=/usr/bin/ahriman web-reload
|
||||||
User=ahriman
|
User=ahriman
|
||||||
Group=ahriman
|
Group=ahriman
|
||||||
|
|
||||||
|
67
src/ahriman/application/handlers/reload.py
Normal file
67
src/ahriman/application/handlers/reload.py
Normal file
@ -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 <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
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]
|
@ -81,6 +81,11 @@ class Client:
|
|||||||
|
|
||||||
return make_local_client()
|
return make_local_client()
|
||||||
|
|
||||||
|
def configuration_reload(self) -> None:
|
||||||
|
"""
|
||||||
|
reload configuration
|
||||||
|
"""
|
||||||
|
|
||||||
def event_add(self, event: Event) -> None:
|
def event_add(self, event: Event) -> None:
|
||||||
"""
|
"""
|
||||||
create new event
|
create new event
|
||||||
|
@ -17,6 +17,7 @@
|
|||||||
# You should have received a copy of the GNU General Public License
|
# You should have received a copy of the GNU General Public License
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
#
|
#
|
||||||
|
# pylint: disable=too-many-public-methods
|
||||||
import contextlib
|
import contextlib
|
||||||
|
|
||||||
from urllib.parse import quote_plus as url_encode
|
from urllib.parse import quote_plus as url_encode
|
||||||
@ -165,6 +166,13 @@ class WebClient(Client, SyncAhrimanClient):
|
|||||||
"""
|
"""
|
||||||
return f"{self.address}/api/v1/status"
|
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:
|
def event_add(self, event: Event) -> None:
|
||||||
"""
|
"""
|
||||||
create new event
|
create new event
|
||||||
|
@ -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.auth_schema import AuthSchema
|
||||||
from ahriman.web.schemas.build_options_schema import BuildOptionsSchema
|
from ahriman.web.schemas.build_options_schema import BuildOptionsSchema
|
||||||
from ahriman.web.schemas.changes_schema import ChangesSchema
|
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.counters_schema import CountersSchema
|
||||||
from ahriman.web.schemas.dependencies_schema import DependenciesSchema
|
from ahriman.web.schemas.dependencies_schema import DependenciesSchema
|
||||||
from ahriman.web.schemas.error_schema import ErrorSchema
|
from ahriman.web.schemas.error_schema import ErrorSchema
|
||||||
|
39
src/ahriman/web/schemas/configuration_schema.py
Normal file
39
src/ahriman/web/schemas/configuration_schema.py
Normal file
@ -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 <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
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",
|
||||||
|
})
|
84
src/ahriman/web/views/v1/service/config.py
Normal file
84
src/ahriman/web/views/v1/service/config.py
Normal file
@ -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 <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
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
|
27
tests/ahriman/application/handlers/test_handler_reload.py
Normal file
27
tests/ahriman/application/handlers/test_handler_reload.py
Normal file
@ -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
|
@ -1563,6 +1563,35 @@ def test_subparsers_web_option_repository(parser: argparse.ArgumentParser) -> No
|
|||||||
assert args.repository == ""
|
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:
|
def test_run(args: argparse.Namespace, configuration: Configuration, mocker: MockerFixture) -> None:
|
||||||
"""
|
"""
|
||||||
application must be run
|
application must be run
|
||||||
|
@ -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)
|
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:
|
def test_event_add(client: Client) -> None:
|
||||||
"""
|
"""
|
||||||
must raise not implemented on event insertion
|
must raise not implemented on event insertion
|
||||||
|
@ -107,6 +107,55 @@ def test_status_url(web_client: WebClient) -> None:
|
|||||||
assert web_client._status_url().endswith("/api/v1/status")
|
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:
|
def test_event_add(web_client: WebClient, package_ahriman: Package, mocker: MockerFixture) -> None:
|
||||||
"""
|
"""
|
||||||
must create event
|
must create event
|
||||||
|
1
tests/ahriman/web/schemas/test_configuration_schema.py
Normal file
1
tests/ahriman/web/schemas/test_configuration_schema.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
# schema testing goes in view class tests
|
@ -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()
|
Reference in New Issue
Block a user