Compare commits

..

1 Commits

Author SHA1 Message Date
c9e52f1b8a feat: add reload command and api endpoints 2025-07-08 11:46:38 +03:00
22 changed files with 381 additions and 60 deletions

View File

@ -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

View 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]

View File

@ -267,8 +267,7 @@ class Pacman(LazyLogging):
Package: list of packages which were returned by the query
"""
def is_package_provided(package: Package) -> bool:
provides = [trim_package(name) for name in package.provides]
return package_name in provides
return package_name in package.provides
for database in self.handle.get_syncdbs():
yield from filter(is_package_provided, database.search(package_name))

View File

@ -146,7 +146,7 @@ class AUR(Remote):
# search api provides reduced models
for stub in self.package_search(package_name, pacman=pacman, search_by="provides")
# verity that found package actually provides it
if package_name in (package := self.package_info(stub.name, pacman=pacman)).provides
if package_name in (package := self.package_info(stub.package_base, pacman=pacman)).provides
]
def package_search(self, *keywords: str, pacman: Pacman | None, search_by: str | None) -> list[AURPackage]:

View File

@ -203,8 +203,6 @@ def migrate_package_repository(connection: Connection, configuration: Configurat
configuration(Configuration): configuration instance
"""
_, repository_id = configuration.check_loaded()
if repository_id.is_empty:
return # no repository available yet
connection.execute("""update build_queue set repository = :repository""", {"repository": repository_id.id})
connection.execute("""update package_bases set repository = :repository""", {"repository": repository_id.id})

View File

@ -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

View File

@ -17,6 +17,7 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
# 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

View File

@ -25,7 +25,7 @@ from dataclasses import dataclass, field, fields
from pyalpm import Package # type: ignore[import-not-found]
from typing import Any, Self
from ahriman.core.utils import filter_json, full_version, trim_package
from ahriman.core.utils import filter_json, full_version
@dataclass(frozen=True, kw_only=True)
@ -103,17 +103,6 @@ class AURPackage:
keywords: list[str] = field(default_factory=list)
groups: list[str] = field(default_factory=list)
def __post_init__(self) -> None:
"""
update packages lists accordingly
"""
object.__setattr__(self, "depends", [trim_package(package) for package in self.depends])
object.__setattr__(self, "make_depends", [trim_package(package) for package in self.make_depends])
object.__setattr__(self, "opt_depends", [trim_package(package) for package in self.opt_depends])
object.__setattr__(self, "check_depends", [trim_package(package) for package in self.check_depends])
object.__setattr__(self, "conflicts", [trim_package(package) for package in self.conflicts])
object.__setattr__(self, "provides", [trim_package(package) for package in self.provides])
@classmethod
def from_json(cls, dump: dict[str, Any]) -> Self:
"""

View File

@ -83,13 +83,12 @@ class PackageDescription:
def __post_init__(self) -> None:
"""
update packages lists accordingly
update dependencies list accordingly
"""
self.depends = [trim_package(package) for package in self.depends]
self.make_depends = [trim_package(package) for package in self.make_depends]
self.opt_depends = [trim_package(package) for package in self.opt_depends]
self.make_depends = [trim_package(package) for package in self.make_depends]
self.check_depends = [trim_package(package) for package in self.check_depends]
self.provides = [trim_package(package) for package in self.provides]
@property
def filepath(self) -> Path | None:

View File

@ -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

View 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",
})

View 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

View 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

View File

@ -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

View File

@ -289,4 +289,3 @@ def test_package_provided_by(pacman: Pacman) -> None:
must search through the provides lists
"""
assert list(pacman.provided_by("sh"))
assert list(pacman.provided_by("libacl.so")) # case with exact version

View File

@ -6,7 +6,6 @@ from unittest.mock import call as MockCall
from ahriman.core.configuration import Configuration
from ahriman.core.database.migrations.m011_repository_name import migrate_data, migrate_package_repository, steps
from ahriman.models.repository_id import RepositoryId
def test_migration_repository_name() -> None:
@ -38,13 +37,3 @@ def test_migrate_package_repository(connection: Connection, configuration: Confi
MockCall(pytest.helpers.anyvar(str, strict=True), {"repository": configuration.repository_id.id}),
MockCall(pytest.helpers.anyvar(str, strict=True), {"repository": configuration.repository_id.id}),
])
def test_migrate_package_repository_empty_id(connection: Connection, configuration: Configuration,
mocker: MockerFixture) -> None:
"""
must do nothing on empty repository id
"""
mocker.patch("ahriman.core.configuration.Configuration.check_loaded", return_value=("", RepositoryId("", "")))
migrate_package_repository(connection, configuration)
connection.execute.assert_not_called()

View File

@ -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

View File

@ -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

View File

@ -2,7 +2,7 @@ import datetime
import json
import pyalpm # typing: ignore
from dataclasses import asdict, fields, replace
from dataclasses import asdict, fields
from pathlib import Path
from pytest_mock import MockerFixture
from typing import Any
@ -38,25 +38,6 @@ def _get_official_data(resource_path_root: Path) -> dict[str, Any]:
return json.loads(response)["results"][0]
def test_post_init(aur_package_ahriman: AURPackage) -> None:
"""
must trim versions and descriptions from packages list
"""
package = replace(
aur_package_ahriman,
depends=["a=1"],
make_depends=["b>=3"],
opt_depends=["c: a description"],
check_depends=["d=4"],
provides=["e=5"],
)
assert package.depends == ["a"]
assert package.make_depends == ["b"]
assert package.opt_depends == ["c"]
assert package.check_depends == ["d"]
assert package.provides == ["e"]
def test_from_json(aur_package_ahriman: AURPackage, resource_path_root: Path) -> None:
"""
must load package from json

View File

@ -6,15 +6,10 @@ from ahriman.models.package_description import PackageDescription
def test_post_init() -> None:
"""
must trim versions and descriptions from packages list
must trim versions and descriptions from dependencies list
"""
assert PackageDescription(
depends=["a=1"],
make_depends=["b>=3"],
opt_depends=["c: a description"],
check_depends=["d=4"],
provides=["e=5"]
) == PackageDescription(depends=["a"], make_depends=["b"], opt_depends=["c"], check_depends=["d"], provides=["e"])
assert PackageDescription(depends=["a=1"], make_depends=["b>=3"], opt_depends=["c: a description"]) == \
PackageDescription(depends=["a"], make_depends=["b"], opt_depends=["c"])
def test_filepath(package_description_ahriman: PackageDescription) -> None:

View File

@ -0,0 +1 @@
# schema testing goes in view class tests

View File

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