Compare commits

..

2 Commits

Author SHA1 Message Date
435375721d fix: fix migrations on empty repositories 2025-07-08 16:46:34 +03:00
4c5caba6b7 fix: trim provides/depends versions and lookup provides through pkgname
(#150)

Current implementation did it in wrong way. First of all, there was a
lookup through pkgbase instead of pkgname, which lead to errors, because
aur api doesn't allow to search by pkgbase (as well as provides is
basically pkgname instead)

It also was found that dependencies resolution lookup has been performed
by using raw packages array, which can include versions, descriptions
etc
2025-07-08 16:22:41 +03:00
22 changed files with 60 additions and 381 deletions

View File

@ -5,7 +5,6 @@ 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

View File

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

View File

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

View File

@ -203,6 +203,8 @@ def migrate_package_repository(connection: Connection, configuration: Configurat
configuration(Configuration): configuration instance configuration(Configuration): configuration instance
""" """
_, repository_id = configuration.check_loaded() _, 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 build_queue set repository = :repository""", {"repository": repository_id.id})
connection.execute("""update package_bases set repository = :repository""", {"repository": repository_id.id}) connection.execute("""update package_bases set repository = :repository""", {"repository": repository_id.id})

View File

@ -81,11 +81,6 @@ 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

View File

@ -17,7 +17,6 @@
# 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
@ -166,13 +165,6 @@ 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

View File

@ -25,7 +25,7 @@ from dataclasses import dataclass, field, fields
from pyalpm import Package # type: ignore[import-not-found] from pyalpm import Package # type: ignore[import-not-found]
from typing import Any, Self from typing import Any, Self
from ahriman.core.utils import filter_json, full_version from ahriman.core.utils import filter_json, full_version, trim_package
@dataclass(frozen=True, kw_only=True) @dataclass(frozen=True, kw_only=True)
@ -103,6 +103,17 @@ class AURPackage:
keywords: list[str] = field(default_factory=list) keywords: list[str] = field(default_factory=list)
groups: 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 @classmethod
def from_json(cls, dump: dict[str, Any]) -> Self: def from_json(cls, dump: dict[str, Any]) -> Self:
""" """

View File

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

View File

@ -22,7 +22,6 @@ 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

View File

@ -1,39 +0,0 @@
#
# 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

@ -1,84 +0,0 @@
#
# 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

@ -1,27 +0,0 @@
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,35 +1563,6 @@ 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

View File

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

View File

@ -6,6 +6,7 @@ from unittest.mock import call as MockCall
from ahriman.core.configuration import Configuration from ahriman.core.configuration import Configuration
from ahriman.core.database.migrations.m011_repository_name import migrate_data, migrate_package_repository, steps 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: def test_migration_repository_name() -> None:
@ -37,3 +38,13 @@ 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}),
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,13 +97,6 @@ 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

View File

@ -107,55 +107,6 @@ 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

View File

@ -2,7 +2,7 @@ import datetime
import json import json
import pyalpm # typing: ignore import pyalpm # typing: ignore
from dataclasses import asdict, fields from dataclasses import asdict, fields, replace
from pathlib import Path from pathlib import Path
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
from typing import Any from typing import Any
@ -38,6 +38,25 @@ def _get_official_data(resource_path_root: Path) -> dict[str, Any]:
return json.loads(response)["results"][0] 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: def test_from_json(aur_package_ahriman: AURPackage, resource_path_root: Path) -> None:
""" """
must load package from json must load package from json

View File

@ -6,10 +6,15 @@ from ahriman.models.package_description import PackageDescription
def test_post_init() -> None: def test_post_init() -> None:
""" """
must trim versions and descriptions from dependencies list must trim versions and descriptions from packages list
""" """
assert PackageDescription(depends=["a=1"], make_depends=["b>=3"], opt_depends=["c: a description"]) == \ assert PackageDescription(
PackageDescription(depends=["a"], make_depends=["b"], opt_depends=["c"]) 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"])
def test_filepath(package_description_ahriman: PackageDescription) -> None: def test_filepath(package_description_ahriman: PackageDescription) -> None:

View File

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

View File

@ -1,54 +0,0 @@
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()