From 7d20d24d86701fd96c42aec0cac3e55e6047e36b Mon Sep 17 00:00:00 2001 From: Evgenii Alekseev Date: Mon, 16 Mar 2026 10:06:15 +0200 Subject: [PATCH] implement support of rollback handler --- docs/ahriman.application.handlers.rst | 8 ++ docs/ahriman.web.schemas.rst | 16 +++ docs/ahriman.web.views.v1.service.rst | 8 ++ .../application/application_repository.py | 7 +- src/ahriman/application/handlers/add.py | 49 +++---- src/ahriman/application/handlers/rollback.py | 131 ++++++++++++++++++ src/ahriman/application/handlers/update.py | 42 +++--- src/ahriman/core/repository/package_info.py | 56 ++++---- src/ahriman/core/spawn.py | 21 +++ src/ahriman/web/schemas/__init__.py | 2 + .../web/schemas/build_options_schema.py | 8 +- src/ahriman/web/schemas/packager_schema.py | 30 ++++ src/ahriman/web/schemas/rollback_schema.py | 40 ++++++ src/ahriman/web/views/base.py | 2 +- src/ahriman/web/views/v1/service/add.py | 1 - src/ahriman/web/views/v1/service/rebuild.py | 1 - src/ahriman/web/views/v1/service/remove.py | 1 - src/ahriman/web/views/v1/service/request.py | 1 - src/ahriman/web/views/v1/service/rollback.py | 77 ++++++++++ src/ahriman/web/views/v1/service/update.py | 3 +- src/ahriman/web/views/v1/service/upload.py | 1 - src/ahriman/web/web.py | 1 + .../test_application_repository.py | 22 ++- .../application/handlers/test_handler_add.py | 112 ++++----------- .../handlers/test_handler_rollback.py | 113 +++++++++++++++ .../handlers/test_handler_update.py | 55 ++++---- tests/ahriman/application/test_ahriman.py | 114 ++++++++++++--- .../core/repository/test_package_info.py | 66 +++++---- tests/ahriman/core/test_spawn.py | 20 +++ .../web/schemas/test_packager_schema.py | 1 + .../web/schemas/test_rollback_schema.py | 1 + .../service/test_view_v1_service_rollback.py | 70 ++++++++++ 32 files changed, 834 insertions(+), 246 deletions(-) create mode 100644 src/ahriman/application/handlers/rollback.py create mode 100644 src/ahriman/web/schemas/packager_schema.py create mode 100644 src/ahriman/web/schemas/rollback_schema.py create mode 100644 src/ahriman/web/views/v1/service/rollback.py create mode 100644 tests/ahriman/application/handlers/test_handler_rollback.py create mode 100644 tests/ahriman/web/schemas/test_packager_schema.py create mode 100644 tests/ahriman/web/schemas/test_rollback_schema.py create mode 100644 tests/ahriman/web/views/v1/service/test_view_v1_service_rollback.py diff --git a/docs/ahriman.application.handlers.rst b/docs/ahriman.application.handlers.rst index 2c8f7f90..a45f0fb9 100644 --- a/docs/ahriman.application.handlers.rst +++ b/docs/ahriman.application.handlers.rst @@ -164,6 +164,14 @@ ahriman.application.handlers.restore module :no-undoc-members: :show-inheritance: +ahriman.application.handlers.rollback module +-------------------------------------------- + +.. automodule:: ahriman.application.handlers.rollback + :members: + :no-undoc-members: + :show-inheritance: + ahriman.application.handlers.run module --------------------------------------- diff --git a/docs/ahriman.web.schemas.rst b/docs/ahriman.web.schemas.rst index 1d51aeca..7a697397 100644 --- a/docs/ahriman.web.schemas.rst +++ b/docs/ahriman.web.schemas.rst @@ -252,6 +252,14 @@ ahriman.web.schemas.package\_version\_schema module :no-undoc-members: :show-inheritance: +ahriman.web.schemas.packager\_schema module +------------------------------------------- + +.. automodule:: ahriman.web.schemas.packager_schema + :members: + :no-undoc-members: + :show-inheritance: + ahriman.web.schemas.pagination\_schema module --------------------------------------------- @@ -332,6 +340,14 @@ ahriman.web.schemas.repository\_stats\_schema module :no-undoc-members: :show-inheritance: +ahriman.web.schemas.rollback\_schema module +------------------------------------------- + +.. automodule:: ahriman.web.schemas.rollback_schema + :members: + :no-undoc-members: + :show-inheritance: + ahriman.web.schemas.search\_schema module ----------------------------------------- diff --git a/docs/ahriman.web.views.v1.service.rst b/docs/ahriman.web.views.v1.service.rst index c9f10f3d..368efec3 100644 --- a/docs/ahriman.web.views.v1.service.rst +++ b/docs/ahriman.web.views.v1.service.rst @@ -68,6 +68,14 @@ ahriman.web.views.v1.service.request module :no-undoc-members: :show-inheritance: +ahriman.web.views.v1.service.rollback module +-------------------------------------------- + +.. automodule:: ahriman.web.views.v1.service.rollback + :members: + :no-undoc-members: + :show-inheritance: + ahriman.web.views.v1.service.search module ------------------------------------------ diff --git a/src/ahriman/application/application/application_repository.py b/src/ahriman/application/application/application_repository.py index 400ace68..ccf8fbfc 100644 --- a/src/ahriman/application/application/application_repository.py +++ b/src/ahriman/application/application/application_repository.py @@ -156,12 +156,15 @@ class ApplicationRepository(ApplicationProperties): result = Result() # process already built packages if any - built_packages = self.repository.packages_built() - if built_packages: # speedup a bit + if built_packages := self.repository.packages_built(): # speedup a bit build_result = self.repository.process_update(built_packages, packagers) self.on_result(build_result) result.merge(build_result) + # filter packages which were prebuilt + succeeded = {package.base for package in build_result.success} + updates = [package for package in updates if package.base not in succeeded] + builder = Updater.load(self.repository_id, self.configuration, self.repository) # ok so for now we split all packages into chunks and process each chunk accordingly diff --git a/src/ahriman/application/handlers/add.py b/src/ahriman/application/handlers/add.py index 6da7b9c5..172cec88 100644 --- a/src/ahriman/application/handlers/add.py +++ b/src/ahriman/application/handlers/add.py @@ -21,10 +21,10 @@ import argparse from ahriman.application.application import Application from ahriman.application.handlers.handler import Handler, SubParserAction +from ahriman.application.handlers.update import Update from ahriman.core.configuration import Configuration from ahriman.core.utils import enum_values, extract_user from ahriman.models.package_source import PackageSource -from ahriman.models.packagers import Packagers from ahriman.models.pkgbuild_patch import PkgbuildPatch from ahriman.models.repository_id import RepositoryId @@ -48,26 +48,7 @@ class Add(Handler): """ application = Application(repository_id, configuration, report=report, refresh_pacman_database=args.refresh) application.on_start() - - application.add(args.package, args.source, args.username) - patches = [PkgbuildPatch.from_env(patch) for patch in args.variable] if args.variable is not None else [] - for package in args.package: # for each requested package insert patch - for patch in patches: - application.reporter.package_patches_update(package, patch) - - if not args.now: - return - - packages = application.updates(args.package, aur=False, local=False, manual=True, vcs=False, check_files=False) - if args.changes: # generate changes if requested - application.changes(packages) - - packages = application.with_dependencies(packages, process_dependencies=args.dependencies) - packagers = Packagers(args.username, {package.base: package.packager for package in packages}) - - application.print_updates(packages, log_fn=application.logger.info) - result = application.update(packages, packagers, bump_pkgrel=args.increment) - Add.check_status(args.exit_code, not result.is_empty) + Add.perform_action(application, args) @staticmethod def _set_package_add_parser(root: SubParserAction) -> argparse.ArgumentParser: @@ -103,14 +84,34 @@ class Add(Handler): parser.add_argument("--increment", help="increment package release (pkgrel) version on duplicate", action=argparse.BooleanOptionalAction, default=True) parser.add_argument("-n", "--now", help="run update function after", action="store_true") - parser.add_argument("-y", "--refresh", help="download fresh package databases from the mirror before actions, " - "-yy to force refresh even if up to date", - action="count", default=False) parser.add_argument("-s", "--source", help="explicitly specify the package source for this command", type=PackageSource, choices=enum_values(PackageSource), default=PackageSource.Auto) parser.add_argument("-u", "--username", help="build as user", default=extract_user()) parser.add_argument("-v", "--variable", help="apply specified makepkg variables to the next build", action="append") + parser.add_argument("-y", "--refresh", help="download fresh package databases from the mirror before actions, " + "-yy to force refresh even if up to date", + action="count", default=False) + parser.set_defaults(aur=False, check_files=False, dry_run=False, local=False, manual=True, vcs=False) return parser + @staticmethod + def perform_action(application: Application, args: argparse.Namespace) -> None: + """ + perform add action + + Args: + application(Application): application instance + args(argparse.Namespace): command line args + """ + application.add(args.package, args.source, args.username) + patches = [PkgbuildPatch.from_env(patch) for patch in args.variable] if args.variable is not None else [] + for package in args.package: # for each requested package insert patch + for patch in patches: + application.reporter.package_patches_update(package, patch) + + if not args.now: + return + Update.perform_action(application, args) + arguments = [_set_package_add_parser] diff --git a/src/ahriman/application/handlers/rollback.py b/src/ahriman/application/handlers/rollback.py new file mode 100644 index 00000000..98f78d0d --- /dev/null +++ b/src/ahriman/application/handlers/rollback.py @@ -0,0 +1,131 @@ +# +# Copyright (c) 2021-2026 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 dataclasses import replace +from pathlib import Path + +from ahriman.application.application import Application +from ahriman.application.handlers.add import Add +from ahriman.application.handlers.handler import Handler, SubParserAction +from ahriman.core.configuration import Configuration +from ahriman.core.exceptions import UnknownPackageError +from ahriman.core.utils import extract_user +from ahriman.models.package import Package +from ahriman.models.package_source import PackageSource +from ahriman.models.repository_id import RepositoryId + + +class Rollback(Handler): + """ + package rollback handler + """ + + @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=report) + application.on_start() + + package = Rollback.package_load(application, args.package, args.version) + artifacts = Rollback.package_artifacts(application, package) + + args.package = [str(artifact) for artifact in artifacts] + Add.perform_action(application, args) + + if args.hold: + application.reporter.package_hold_update(package.base, enabled=True) + + @staticmethod + def _set_package_rollback_parser(root: SubParserAction) -> argparse.ArgumentParser: + """ + add parser for package rollback subcommand + + Args: + root(SubParserAction): subparsers for the commands + + Returns: + argparse.ArgumentParser: created argument parser + """ + parser = root.add_parser("package-rollback", help="rollback package", + description="rollback package to specified version from archives") + parser.add_argument("package", help="package base") + parser.add_argument("version", help="package version") + parser.add_argument("--hold", help="hold package afterwards", + action=argparse.BooleanOptionalAction, default=True) + parser.add_argument("-u", "--username", help="build as user", default=extract_user()) + parser.set_defaults(aur=False, changes=False, check_files=False, dependencies=False, dry_run=False, + exit_code=True, increment=False, now=True, local=False, manual=False, refresh=False, + source=PackageSource.Archive, variable=None, vcs=False) + return parser + + @staticmethod + def package_artifacts(application: Application, package: Package) -> list[Path]: + """ + look for requested package artifacts and return paths to them + + Args: + application(Application): application instance + package(Package): package descriptor + + Returns: + list[Path]: paths to found artifacts + + Raises: + UnknownPackageError: if artifacts do not exist + """ + # lookup for built artifacts + artifacts = application.repository.package_archives_lookup(package) + if not artifacts: + raise UnknownPackageError(package.base) + return artifacts + + @staticmethod + def package_load(application: Application, package_base: str, version: str) -> Package: + """ + load package from repository, while setting requested version + + Args: + application(Application): application instance + package_base(str): package base + version(str): package version + + Returns: + Package: loaded package + + Raises: + UnknownPackageError: if package does not exist + """ + try: + package, _ = next(iter(application.reporter.package_get(package_base))) + return replace(package, version=version) + except StopIteration: + raise UnknownPackageError(package_base) from None + + arguments = [_set_package_rollback_parser] diff --git a/src/ahriman/application/handlers/update.py b/src/ahriman/application/handlers/update.py index 239ef12a..5fa10f25 100644 --- a/src/ahriman/application/handlers/update.py +++ b/src/ahriman/application/handlers/update.py @@ -48,22 +48,7 @@ class Update(Handler): """ application = Application(repository_id, configuration, report=report, refresh_pacman_database=args.refresh) application.on_start() - - packages = application.updates(args.package, aur=args.aur, local=args.local, manual=args.manual, vcs=args.vcs, - check_files=args.check_files) - if args.changes: # generate changes if requested - application.changes(packages) - - if args.dry_run: # exit from application if no build requested - Update.check_status(args.exit_code, packages) # status code check - return - - packages = application.with_dependencies(packages, process_dependencies=args.dependencies) - packagers = Packagers(args.username, {package.base: package.packager for package in packages}) - - application.print_updates(packages, log_fn=application.logger.info) - result = application.update(packages, packagers, bump_pkgrel=args.increment) - Update.check_status(args.exit_code, not result.is_empty) + Update.perform_action(application, args) @staticmethod def _set_repo_check_parser(root: SubParserAction) -> argparse.ArgumentParser: @@ -153,6 +138,31 @@ class Update(Handler): return print(line) if dry_run else application.logger.info(line) # pylint: disable=bad-builtin return inner + @staticmethod + def perform_action(application: Application, args: argparse.Namespace) -> None: + """ + perform update action + + Args: + application(Application): application instance + args(argparse.Namespace): command line args + """ + packages = application.updates(args.package, aur=args.aur, local=args.local, manual=args.manual, vcs=args.vcs, + check_files=args.check_files) + if args.changes: # generate changes if requested + application.changes(packages) + + if args.dry_run: # exit from application if no build requested + Update.check_status(args.exit_code, packages) # status code check + return + + packages = application.with_dependencies(packages, process_dependencies=args.dependencies) + packagers = Packagers(args.username, {package.base: package.packager for package in packages}) + + application.print_updates(packages, log_fn=application.logger.info) + result = application.update(packages, packagers, bump_pkgrel=args.increment) + Update.check_status(args.exit_code, not result.is_empty) + arguments = [ _set_repo_check_parser, _set_repo_update_parser, diff --git a/src/ahriman/core/repository/package_info.py b/src/ahriman/core/repository/package_info.py index be5f4560..b192491b 100644 --- a/src/ahriman/core/repository/package_info.py +++ b/src/ahriman/core/repository/package_info.py @@ -25,7 +25,6 @@ from pathlib import Path from tempfile import TemporaryDirectory from ahriman.core.alpm.pacman import Pacman -from ahriman.core.build_tools.package_version import PackageVersion from ahriman.core.build_tools.sources import Sources from ahriman.core.configuration import Configuration from ahriman.core.log import LazyLogging @@ -89,19 +88,21 @@ class PackageInfo(LazyLogging): return sorted(result) - def load_archives(self, packages: Iterable[Path]) -> list[Package]: + def load_archives(self, packages: Iterable[Path], *, latest_only: bool = True) -> list[Package]: """ load packages from list of archives Args: packages(Iterable[Path]): paths to package archives + latest_only(bool, optional): filter packages with the same base, keeping only fresh packages installed + (Default value = True) Returns: list[Package]: list of read packages """ sources = {package.base: package.remote for package, _, in self.reporter.package_get(None)} - result: dict[str, Package] = {} + result: dict[str, dict[str, Package]] = {} # we are iterating over bases, not single packages for full_path in packages: try: @@ -109,17 +110,23 @@ class PackageInfo(LazyLogging): if (source := sources.get(local.base)) is not None: # update source with remote local.remote = source - current = result.setdefault(local.base, local) - if current.version != local.version: - # force version to max of them - self.logger.warning("version of %s differs, found %s and %s", - current.base, current.version, local.version) - if PackageVersion(current).is_outdated(local, self.configuration, calculate_version=False): - current.version = local.version + loaded_versions = result.setdefault(local.base, {}) + current = loaded_versions.setdefault(local.version, local) current.packages.update(local.packages) except Exception: self.logger.exception("could not load package from %s", full_path) - return list(result.values()) + + if latest_only: + comparator: Callable[[Package, Package], int] = lambda left, right: left.vercmp(right.version) + for package_base, versions in result.items(): + newest = max(versions.values(), key=cmp_to_key(comparator)) + result[package_base] = {newest.version: newest} + + return [ + package + for versions in result.values() + for package in versions.values() + ] def package_archives(self, package_base: str) -> list[Package]: """ @@ -133,16 +140,17 @@ class PackageInfo(LazyLogging): Returns: list[Package]: list of packages belonging to this base, sorted by version by ascension """ - packages: dict[tuple[str, str], Package] = {} - # we can't use here load_archives, because it ignores versions - for full_path in filter(package_like, self.paths.archive_for(package_base).iterdir()): - local = Package.from_archive(full_path) - if not local.supports_architecture(self.repository_id.architecture): - continue - packages.setdefault((local.base, local.version), local).packages.update(local.packages) + archive = self.paths.archive_for(package_base) + if not archive.is_dir(): + return [] + + packages = self.load_archives(filter(package_like, archive.iterdir()), latest_only=False) comparator: Callable[[Package, Package], int] = lambda left, right: left.vercmp(right.version) - return sorted(packages.values(), key=cmp_to_key(comparator)) + return sorted( + (package for package in packages if package.supports_architecture(self.repository_id.architecture)), + key=cmp_to_key(comparator), + ) def package_archives_lookup(self, package: Package) -> list[Path]: """ @@ -155,19 +163,11 @@ class PackageInfo(LazyLogging): list[Path]: list of built packages and signatures if available, empty list otherwise """ archive = self.paths.archive_for(package.base) - if not archive.is_dir(): - return [] - for path in filter(package_like, archive.iterdir()): - # check if package version is the same - built = Package.from_archive(path) + for built in self.package_archives(package.base): if built.version != package.version: continue - # all packages must be either any or same architecture - if not built.supports_architecture(self.repository_id.architecture): - continue - return list_flatmap(built.packages.values(), lambda single: archive.glob(f"{single.filename}*")) return [] diff --git a/src/ahriman/core/spawn.py b/src/ahriman/core/spawn.py index 09a857d8..39c28a2e 100644 --- a/src/ahriman/core/spawn.py +++ b/src/ahriman/core/spawn.py @@ -232,6 +232,27 @@ class Spawn(Thread, LazyLogging): """ return self._spawn_process(repository_id, "package-remove", *packages) + def packages_rollback(self, repository_id: RepositoryId, package: str, version: str, username: str | None, *, + hold: bool) -> str: + """ + rollback package + + Args: + repository_id(RepositoryId): repository unique identifier + package(str): package base to rollback + version(str): package version to rollback + username(str | None): optional override of username for build process + hold(bool): hold package after rollback + + Returns: + str: spawned process identifier + """ + kwargs = { + "username": username, + self.boolean_action_argument("hold", hold): "", + } + return self._spawn_process(repository_id, "package-rollback", package, version, **kwargs) + def packages_update(self, repository_id: RepositoryId, username: str | None, *, aur: bool, local: bool, manual: bool, increment: bool, refresh: bool) -> str: """ diff --git a/src/ahriman/web/schemas/__init__.py b/src/ahriman/web/schemas/__init__.py index 8347f30f..e5e5f2c5 100644 --- a/src/ahriman/web/schemas/__init__.py +++ b/src/ahriman/web/schemas/__init__.py @@ -48,6 +48,7 @@ from ahriman.web.schemas.package_properties_schema import PackagePropertiesSchem from ahriman.web.schemas.package_schema import PackageSchema from ahriman.web.schemas.package_status_schema import PackageStatusSchema, PackageStatusSimplifiedSchema from ahriman.web.schemas.package_version_schema import PackageVersionSchema +from ahriman.web.schemas.packager_schema import PackagerSchema from ahriman.web.schemas.pagination_schema import PaginationSchema from ahriman.web.schemas.patch_name_schema import PatchNameSchema from ahriman.web.schemas.patch_schema import PatchSchema @@ -58,6 +59,7 @@ from ahriman.web.schemas.process_schema import ProcessSchema from ahriman.web.schemas.remote_schema import RemoteSchema from ahriman.web.schemas.repository_id_schema import RepositoryIdSchema from ahriman.web.schemas.repository_stats_schema import RepositoryStatsSchema +from ahriman.web.schemas.rollback_schema import RollbackSchema from ahriman.web.schemas.search_schema import SearchSchema from ahriman.web.schemas.status_schema import StatusSchema from ahriman.web.schemas.update_flags_schema import UpdateFlagsSchema diff --git a/src/ahriman/web/schemas/build_options_schema.py b/src/ahriman/web/schemas/build_options_schema.py index f914d485..48d712a5 100644 --- a/src/ahriman/web/schemas/build_options_schema.py +++ b/src/ahriman/web/schemas/build_options_schema.py @@ -17,10 +17,11 @@ # 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 +from ahriman.web.apispec import fields +from ahriman.web.schemas.packager_schema import PackagerSchema -class BuildOptionsSchema(Schema): +class BuildOptionsSchema(PackagerSchema): """ request build options schema """ @@ -28,9 +29,6 @@ class BuildOptionsSchema(Schema): increment = fields.Boolean(dump_default=True, metadata={ "description": "Increment pkgrel on conflicts", }) - packager = fields.String(metadata={ - "description": "Packager identity if applicable", - }) refresh = fields.Boolean(dump_default=True, metadata={ "description": "Refresh pacman database" }) diff --git a/src/ahriman/web/schemas/packager_schema.py b/src/ahriman/web/schemas/packager_schema.py new file mode 100644 index 00000000..16380338 --- /dev/null +++ b/src/ahriman/web/schemas/packager_schema.py @@ -0,0 +1,30 @@ +# +# Copyright (c) 2021-2026 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 PackagerSchema(Schema): + """ + request packager schema + """ + + packager = fields.String(metadata={ + "description": "Packager identity if applicable", + }) diff --git a/src/ahriman/web/schemas/rollback_schema.py b/src/ahriman/web/schemas/rollback_schema.py new file mode 100644 index 00000000..8d9ef7ae --- /dev/null +++ b/src/ahriman/web/schemas/rollback_schema.py @@ -0,0 +1,40 @@ +# +# Copyright (c) 2021-2026 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 import __version__ +from ahriman.web.apispec import fields +from ahriman.web.schemas.packager_schema import PackagerSchema + + +class RollbackSchema(PackagerSchema): + """ + request schema for package rollback + """ + + hold = fields.Boolean(dump_default=True, metadata={ + "description": "Hold package after rollback", + }) + package = fields.String(required=True, metadata={ + "description": "Package name", + "example": "ahriman", + }) + version = fields.String(required=True, metadata={ + "description": "Package version", + "example": __version__, + }) diff --git a/src/ahriman/web/views/base.py b/src/ahriman/web/views/base.py index 5a3f271f..a38f22c9 100644 --- a/src/ahriman/web/views/base.py +++ b/src/ahriman/web/views/base.py @@ -227,7 +227,7 @@ class BaseView(View, CorsViewMixin): extract repository from request Returns: - RepositoryIde: repository if possible to construct and first one otherwise + RepositoryId: repository if possible to construct and first one otherwise """ architecture = self.request.query.get("architecture") name = self.request.query.get("repository") diff --git a/src/ahriman/web/views/v1/service/add.py b/src/ahriman/web/views/v1/service/add.py index a0356f77..ec94f605 100644 --- a/src/ahriman/web/views/v1/service/add.py +++ b/src/ahriman/web/views/v1/service/add.py @@ -44,7 +44,6 @@ class AddView(BaseView): description="Add new package(s) from AUR", permission=POST_PERMISSION, error_400_enabled=True, - error_404_description="Repository is unknown", schema=ProcessIdSchema, query_schema=RepositoryIdSchema, body_schema=PackagePatchSchema, diff --git a/src/ahriman/web/views/v1/service/rebuild.py b/src/ahriman/web/views/v1/service/rebuild.py index 0e88749d..6499fb71 100644 --- a/src/ahriman/web/views/v1/service/rebuild.py +++ b/src/ahriman/web/views/v1/service/rebuild.py @@ -43,7 +43,6 @@ class RebuildView(BaseView): description="Rebuild packages which depend on specified one", permission=POST_PERMISSION, error_400_enabled=True, - error_404_description="Repository is unknown", schema=ProcessIdSchema, query_schema=RepositoryIdSchema, body_schema=PackageNamesSchema, diff --git a/src/ahriman/web/views/v1/service/remove.py b/src/ahriman/web/views/v1/service/remove.py index 8a21589a..3d0f62f5 100644 --- a/src/ahriman/web/views/v1/service/remove.py +++ b/src/ahriman/web/views/v1/service/remove.py @@ -43,7 +43,6 @@ class RemoveView(BaseView): description="Remove specified packages from the repository", permission=POST_PERMISSION, error_400_enabled=True, - error_404_description="Repository is unknown", schema=ProcessIdSchema, query_schema=RepositoryIdSchema, body_schema=PackageNamesSchema, diff --git a/src/ahriman/web/views/v1/service/request.py b/src/ahriman/web/views/v1/service/request.py index 55eb2e28..d56a8bc0 100644 --- a/src/ahriman/web/views/v1/service/request.py +++ b/src/ahriman/web/views/v1/service/request.py @@ -44,7 +44,6 @@ class RequestView(BaseView): description="Request new package(s) to be added from AUR", permission=POST_PERMISSION, error_400_enabled=True, - error_404_description="Repository is unknown", schema=ProcessIdSchema, query_schema=RepositoryIdSchema, body_schema=PackagePatchSchema, diff --git a/src/ahriman/web/views/v1/service/rollback.py b/src/ahriman/web/views/v1/service/rollback.py new file mode 100644 index 00000000..348a0316 --- /dev/null +++ b/src/ahriman/web/views/v1/service/rollback.py @@ -0,0 +1,77 @@ +# +# Copyright (c) 2021-2026 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 HTTPBadRequest, Response +from typing import ClassVar + +from ahriman.models.user_access import UserAccess +from ahriman.web.apispec.decorators import apidocs +from ahriman.web.schemas import ProcessIdSchema, RepositoryIdSchema, RollbackSchema +from ahriman.web.views.base import BaseView + + +class RollbackView(BaseView): + """ + package rollback web view + + Attributes: + POST_PERMISSION(UserAccess): (class attribute) post permissions of self + """ + + POST_PERMISSION: ClassVar[UserAccess] = UserAccess.Full + ROUTES = ["/api/v1/service/rollback"] + + @apidocs( + tags=["Actions"], + summary="Rollback package", + description="Rollback package to specified version", + permission=POST_PERMISSION, + error_400_enabled=True, + schema=ProcessIdSchema, + query_schema=RepositoryIdSchema, + body_schema=RollbackSchema, + ) + async def post(self) -> Response: + """ + run package rollback + + Returns: + Response: 200 with spawned process id + + Raises: + HTTPBadRequest: if bad data is supplied + """ + try: + data = await self.request.json() + package = self.get_non_empty(lambda key: data[key], "package") + version = self.get_non_empty(lambda key: data[key], "version") + except Exception as ex: + raise HTTPBadRequest(reason=str(ex)) + + repository_id = self.repository_id() + username = await self.username() + process_id = self.spawner.packages_rollback( + repository_id, + package, + version, + username, + hold=data.get("hold", True), + ) + + return self.json_response({"process_id": process_id}) diff --git a/src/ahriman/web/views/v1/service/update.py b/src/ahriman/web/views/v1/service/update.py index bc95b457..955bd9bd 100644 --- a/src/ahriman/web/views/v1/service/update.py +++ b/src/ahriman/web/views/v1/service/update.py @@ -43,14 +43,13 @@ class UpdateView(BaseView): description="Run repository update process", permission=POST_PERMISSION, error_400_enabled=True, - error_404_description="Repository is unknown", schema=ProcessIdSchema, query_schema=RepositoryIdSchema, body_schema=UpdateFlagsSchema, ) async def post(self) -> Response: """ - run repository update. No parameters supported here + run repository update Returns: Response: 200 with spawned process id diff --git a/src/ahriman/web/views/v1/service/upload.py b/src/ahriman/web/views/v1/service/upload.py index 145df917..54d1a52f 100644 --- a/src/ahriman/web/views/v1/service/upload.py +++ b/src/ahriman/web/views/v1/service/upload.py @@ -118,7 +118,6 @@ class UploadView(BaseView): permission=POST_PERMISSION, response_code=HTTPCreated, error_400_enabled=True, - error_404_description="Repository is unknown", query_schema=RepositoryIdSchema, body_schema=FileSchema, body_location="form", diff --git a/src/ahriman/web/web.py b/src/ahriman/web/web.py index 0eab64da..a40ea234 100644 --- a/src/ahriman/web/web.py +++ b/src/ahriman/web/web.py @@ -104,6 +104,7 @@ def _create_watcher(path: Path, repository_id: RepositoryId) -> Watcher: package_info = PackageInfo() package_info.configuration = configuration package_info.paths = configuration.repository_paths + package_info.reporter = client package_info.repository_id = repository_id return Watcher(client, package_info) diff --git a/tests/ahriman/application/application/test_application_repository.py b/tests/ahriman/application/application/test_application_repository.py index d3ca9a2b..d9d698b6 100644 --- a/tests/ahriman/application/application/test_application_repository.py +++ b/tests/ahriman/application/application/test_application_repository.py @@ -190,13 +190,14 @@ def test_update(application_repository: ApplicationRepository, package_ahriman: """ paths = [package.filepath for package in package_ahriman.packages.values()] tree = Tree([Leaf(package_ahriman)]) + prebuilt_result = Result() resolve_mock = mocker.patch("ahriman.application.application.workers.local_updater.LocalUpdater.partition", return_value=tree.levels()) mocker.patch("ahriman.core.repository.repository.Repository.packages_built", return_value=paths) build_mock = mocker.patch("ahriman.application.application.workers.local_updater.LocalUpdater.update", return_value=result) - update_mock = mocker.patch("ahriman.core.repository.Repository.process_update", return_value=result) + update_mock = mocker.patch("ahriman.core.repository.Repository.process_update", return_value=prebuilt_result) on_result_mock = mocker.patch( "ahriman.application.application.application_repository.ApplicationRepository.on_result") @@ -204,7 +205,24 @@ def test_update(application_repository: ApplicationRepository, package_ahriman: resolve_mock.assert_called_once_with([package_ahriman]) build_mock.assert_called_once_with([package_ahriman], Packagers("username"), bump_pkgrel=True) update_mock.assert_called_once_with(paths, Packagers("username")) - on_result_mock.assert_has_calls([MockCall(result), MockCall(result)]) + on_result_mock.assert_has_calls([MockCall(prebuilt_result), MockCall(result)]) + + +def test_update_prebuilt_filter(application_repository: ApplicationRepository, package_ahriman: Package, result: Result, + mocker: MockerFixture) -> None: + """ + must filter out packages which were successfully prebuilt + """ + paths = [package.filepath for package in package_ahriman.packages.values()] + + resolve_mock = mocker.patch("ahriman.application.application.workers.local_updater.LocalUpdater.partition", + return_value=[]) + mocker.patch("ahriman.core.repository.repository.Repository.packages_built", return_value=paths) + mocker.patch("ahriman.core.repository.Repository.process_update", return_value=result) + mocker.patch("ahriman.application.application.application_repository.ApplicationRepository.on_result") + + application_repository.update([package_ahriman], Packagers("username"), bump_pkgrel=True) + resolve_mock.assert_called_once_with([]) def test_update_empty(application_repository: ApplicationRepository, package_ahriman: Package, result: Result, diff --git a/tests/ahriman/application/handlers/test_handler_add.py b/tests/ahriman/application/handlers/test_handler_add.py index f9507ab0..17d82aeb 100644 --- a/tests/ahriman/application/handlers/test_handler_add.py +++ b/tests/ahriman/application/handlers/test_handler_add.py @@ -3,14 +3,12 @@ import pytest from pytest_mock import MockerFixture +from ahriman.application.application import Application from ahriman.application.handlers.add import Add from ahriman.core.configuration import Configuration from ahriman.core.repository import Repository -from ahriman.models.package import Package from ahriman.models.package_source import PackageSource -from ahriman.models.packagers import Packagers from ahriman.models.pkgbuild_patch import PkgbuildPatch -from ahriman.models.result import Result def _default_args(args: argparse.Namespace) -> argparse.Namespace: @@ -24,13 +22,9 @@ def _default_args(args: argparse.Namespace) -> argparse.Namespace: argparse.Namespace: generated arguments for these test cases """ args.package = ["ahriman"] - args.changes = True - args.exit_code = False - args.increment = True args.now = False args.refresh = 0 args.source = PackageSource.Auto - args.dependencies = True args.username = "username" args.variable = None return args @@ -43,103 +37,49 @@ def test_run(args: argparse.Namespace, configuration: Configuration, repository: """ args = _default_args(args) mocker.patch("ahriman.core.repository.Repository.load", return_value=repository) - application_mock = mocker.patch("ahriman.application.application.Application.add") - dependencies_mock = mocker.patch("ahriman.application.application.Application.with_dependencies") on_start_mock = mocker.patch("ahriman.application.application.Application.on_start") + perform_mock = mocker.patch("ahriman.application.handlers.add.Add.perform_action") _, repository_id = configuration.check_loaded() Add.run(args, repository_id, configuration, report=False) - application_mock.assert_called_once_with(args.package, args.source, args.username) - dependencies_mock.assert_not_called() on_start_mock.assert_called_once_with() + perform_mock.assert_called_once_with(pytest.helpers.anyvar(int), args) -def test_run_with_patches(args: argparse.Namespace, configuration: Configuration, repository: Repository, - mocker: MockerFixture) -> None: +def test_perform_action(args: argparse.Namespace, application: Application, mocker: MockerFixture) -> None: """ - must run command and insert temporary patches + must perform add action + """ + args = _default_args(args) + application_mock = mocker.patch("ahriman.application.application.Application.add") + update_mock = mocker.patch("ahriman.application.handlers.update.Update.perform_action") + + Add.perform_action(application, args) + application_mock.assert_called_once_with(args.package, args.source, args.username) + update_mock.assert_not_called() + + +def test_perform_action_with_patches(args: argparse.Namespace, application: Application, mocker: MockerFixture) -> None: + """ + must perform add action and insert temporary patches """ args = _default_args(args) args.variable = ["KEY=VALUE"] - mocker.patch("ahriman.core.repository.Repository.load", return_value=repository) mocker.patch("ahriman.application.application.Application.add") - application_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_patches_update") + patches_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_patches_update") - _, repository_id = configuration.check_loaded() - Add.run(args, repository_id, configuration, report=False) - application_mock.assert_called_once_with(args.package[0], PkgbuildPatch("KEY", "VALUE")) + Add.perform_action(application, args) + patches_mock.assert_called_once_with(args.package[0], PkgbuildPatch("KEY", "VALUE")) -def test_run_with_updates(args: argparse.Namespace, configuration: Configuration, repository: Repository, - package_ahriman: Package, mocker: MockerFixture) -> None: +def test_perform_action_with_updates(args: argparse.Namespace, application: Application, mocker: MockerFixture) -> None: """ - must run command with updates after + must perform add action with updates after """ args = _default_args(args) args.now = True - result = Result() - result.add_updated(package_ahriman) mocker.patch("ahriman.application.application.Application.add") - mocker.patch("ahriman.core.repository.Repository.load", return_value=repository) - application_mock = mocker.patch("ahriman.application.application.Application.update", return_value=result) - check_mock = mocker.patch("ahriman.application.handlers.handler.Handler.check_status") - changes_mock = mocker.patch("ahriman.application.application.Application.changes") - updates_mock = mocker.patch("ahriman.application.application.Application.updates", return_value=[package_ahriman]) - dependencies_mock = mocker.patch("ahriman.application.application.Application.with_dependencies", - return_value=[package_ahriman]) - print_mock = mocker.patch("ahriman.application.application.Application.print_updates") + update_mock = mocker.patch("ahriman.application.handlers.update.Update.perform_action") - _, repository_id = configuration.check_loaded() - Add.run(args, repository_id, configuration, report=False) - updates_mock.assert_called_once_with(args.package, - aur=False, local=False, manual=True, vcs=False, check_files=False) - changes_mock.assert_called_once_with([package_ahriman]) - application_mock.assert_called_once_with([package_ahriman], - Packagers(args.username, {package_ahriman.base: "packager"}), - bump_pkgrel=args.increment) - dependencies_mock.assert_called_once_with([package_ahriman], process_dependencies=args.dependencies) - check_mock.assert_called_once_with(False, True) - print_mock.assert_called_once_with([package_ahriman], log_fn=pytest.helpers.anyvar(int)) - - -def test_run_no_changes(args: argparse.Namespace, configuration: Configuration, repository: Repository, - mocker: MockerFixture) -> None: - """ - must skip changes calculation during package addition - """ - args = _default_args(args) - args.now = True - args.changes = False - mocker.patch("ahriman.application.application.Application.add") - mocker.patch("ahriman.core.repository.Repository.load", return_value=repository) - mocker.patch("ahriman.application.application.Application.update") - mocker.patch("ahriman.application.handlers.handler.Handler.check_status") - mocker.patch("ahriman.application.application.Application.updates") - mocker.patch("ahriman.application.application.Application.with_dependencies") - mocker.patch("ahriman.application.application.Application.print_updates") - changes_mock = mocker.patch("ahriman.application.application.Application.changes") - - _, repository_id = configuration.check_loaded() - Add.run(args, repository_id, configuration, report=False) - changes_mock.assert_not_called() - - -def test_run_empty_exception(args: argparse.Namespace, configuration: Configuration, repository: Repository, - mocker: MockerFixture) -> None: - """ - must raise ExitCode exception on empty result - """ - args = _default_args(args) - args.now = True - args.exit_code = True - mocker.patch("ahriman.application.application.Application.add") - mocker.patch("ahriman.core.repository.Repository.load", return_value=repository) - mocker.patch("ahriman.application.application.Application.update", return_value=Result()) - mocker.patch("ahriman.application.application.Application.with_dependencies") - mocker.patch("ahriman.application.application.Application.updates") - mocker.patch("ahriman.application.application.Application.print_updates") - check_mock = mocker.patch("ahriman.application.handlers.handler.Handler.check_status") - - _, repository_id = configuration.check_loaded() - Add.run(args, repository_id, configuration, report=False) - check_mock.assert_called_once_with(True, False) + Add.perform_action(application, args) + update_mock.assert_called_once_with(application, args) diff --git a/tests/ahriman/application/handlers/test_handler_rollback.py b/tests/ahriman/application/handlers/test_handler_rollback.py new file mode 100644 index 00000000..d32fef97 --- /dev/null +++ b/tests/ahriman/application/handlers/test_handler_rollback.py @@ -0,0 +1,113 @@ +import argparse +import pytest + +from pytest_mock import MockerFixture + +from ahriman.application.application import Application +from ahriman.application.handlers.rollback import Rollback +from ahriman.core.configuration import Configuration +from ahriman.core.exceptions import UnknownPackageError +from ahriman.core.repository import Repository +from ahriman.models.package import Package + + +def _default_args(args: argparse.Namespace) -> argparse.Namespace: + """ + default arguments for these test cases + + Args: + args(argparse.Namespace): command line arguments fixture + + Returns: + argparse.Namespace: generated arguments for these test cases + """ + args.package = "ahriman" + args.version = "1.0.0-1" + args.hold = False + return args + + +def test_run(args: argparse.Namespace, configuration: Configuration, repository: Repository, package_ahriman: Package, + mocker: MockerFixture) -> None: + """ + must run command + """ + args = _default_args(args) + artifacts = [package.filepath for package in package_ahriman.packages.values()] + mocker.patch("ahriman.core.repository.Repository.load", return_value=repository) + on_start_mock = mocker.patch("ahriman.application.application.Application.on_start") + load_mock = mocker.patch("ahriman.application.handlers.rollback.Rollback.package_load", + return_value=package_ahriman) + artifacts_mock = mocker.patch("ahriman.application.handlers.rollback.Rollback.package_artifacts", + return_value=artifacts) + perform_mock = mocker.patch("ahriman.application.handlers.add.Add.perform_action") + hold_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_hold_update") + + _, repository_id = configuration.check_loaded() + Rollback.run(args, repository_id, configuration, report=False) + on_start_mock.assert_called_once_with() + load_mock.assert_called_once_with(pytest.helpers.anyvar(int), package_ahriman.base, args.version) + artifacts_mock.assert_called_once_with(pytest.helpers.anyvar(int), package_ahriman) + perform_mock.assert_called_once_with(pytest.helpers.anyvar(int), args) + hold_mock.assert_not_called() + + +def test_run_hold(args: argparse.Namespace, configuration: Configuration, repository: Repository, + package_ahriman: Package, mocker: MockerFixture) -> None: + """ + must hold package after rollback + """ + args = _default_args(args) + args.hold = True + mocker.patch("ahriman.core.repository.Repository.load", return_value=repository) + mocker.patch("ahriman.application.application.Application.on_start") + mocker.patch("ahriman.application.handlers.rollback.Rollback.package_load", return_value=package_ahriman) + mocker.patch("ahriman.application.handlers.rollback.Rollback.package_artifacts", return_value=[]) + mocker.patch("ahriman.application.handlers.add.Add.perform_action") + hold_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_hold_update") + + _, repository_id = configuration.check_loaded() + Rollback.run(args, repository_id, configuration, report=False) + hold_mock.assert_called_once_with(package_ahriman.base, enabled=True) + + +def test_package_artifacts(application: Application, package_ahriman: Package, mocker: MockerFixture) -> None: + """ + must return package artifacts + """ + artifacts = [package.filepath for package in package_ahriman.packages.values()] + lookup_mock = mocker.patch("ahriman.core.repository.Repository.package_archives_lookup", return_value=artifacts) + + assert Rollback.package_artifacts(application, package_ahriman) == artifacts + lookup_mock.assert_called_once_with(package_ahriman) + + +def test_package_artifacts_empty(application: Application, package_ahriman: Package, + mocker: MockerFixture) -> None: + """ + must raise UnknownPackageError if no artifacts found + """ + mocker.patch("ahriman.core.repository.Repository.package_archives_lookup", return_value=[]) + with pytest.raises(UnknownPackageError): + Rollback.package_artifacts(application, package_ahriman) + + +def test_package_load(application: Application, package_ahriman: Package, mocker: MockerFixture) -> None: + """ + must load package from reporter + """ + package_mock = mocker.patch("ahriman.core.status.local_client.LocalClient.package_get", + return_value=[(package_ahriman, None)]) + + result = Rollback.package_load(application, package_ahriman.base, "2.0.0-1") + assert result.version == "2.0.0-1" + package_mock.assert_called_once_with(package_ahriman.base) + + +def test_package_load_unknown(application: Application, package_ahriman: Package, mocker: MockerFixture) -> None: + """ + must raise UnknownPackageError if package not found + """ + mocker.patch("ahriman.core.status.local_client.LocalClient.package_get", return_value=[]) + with pytest.raises(UnknownPackageError): + Rollback.package_load(application, package_ahriman.base, package_ahriman.version) diff --git a/tests/ahriman/application/handlers/test_handler_update.py b/tests/ahriman/application/handlers/test_handler_update.py index 428ddbde..f0290e8a 100644 --- a/tests/ahriman/application/handlers/test_handler_update.py +++ b/tests/ahriman/application/handlers/test_handler_update.py @@ -39,26 +39,39 @@ def _default_args(args: argparse.Namespace) -> argparse.Namespace: return args -def test_run(args: argparse.Namespace, package_ahriman: Package, configuration: Configuration, repository: Repository, +def test_run(args: argparse.Namespace, configuration: Configuration, repository: Repository, mocker: MockerFixture) -> None: """ must run command """ args = _default_args(args) + mocker.patch("ahriman.core.repository.Repository.load", return_value=repository) + on_start_mock = mocker.patch("ahriman.application.application.Application.on_start") + perform_mock = mocker.patch("ahriman.application.handlers.update.Update.perform_action") + + _, repository_id = configuration.check_loaded() + Update.run(args, repository_id, configuration, report=False) + on_start_mock.assert_called_once_with() + perform_mock.assert_called_once_with(pytest.helpers.anyvar(int), args) + + +def test_perform_action(args: argparse.Namespace, application: Application, package_ahriman: Package, + mocker: MockerFixture) -> None: + """ + must perform update action + """ + args = _default_args(args) result = Result() result.add_updated(package_ahriman) - mocker.patch("ahriman.core.repository.Repository.load", return_value=repository) application_mock = mocker.patch("ahriman.application.application.Application.update", return_value=result) check_mock = mocker.patch("ahriman.application.handlers.handler.Handler.check_status") dependencies_mock = mocker.patch("ahriman.application.application.Application.with_dependencies", return_value=[package_ahriman]) updates_mock = mocker.patch("ahriman.application.application.Application.updates", return_value=[package_ahriman]) changes_mock = mocker.patch("ahriman.application.application.Application.changes") - on_start_mock = mocker.patch("ahriman.application.application.Application.on_start") print_mock = mocker.patch("ahriman.application.application.Application.print_updates") - _, repository_id = configuration.check_loaded() - Update.run(args, repository_id, configuration, report=False) + Update.perform_action(application, args) application_mock.assert_called_once_with([package_ahriman], Packagers(args.username, {package_ahriman.base: "packager"}), bump_pkgrel=args.increment) @@ -67,35 +80,31 @@ def test_run(args: argparse.Namespace, package_ahriman: Package, configuration: changes_mock.assert_called_once_with([package_ahriman]) dependencies_mock.assert_called_once_with([package_ahriman], process_dependencies=args.dependencies) check_mock.assert_called_once_with(False, True) - on_start_mock.assert_called_once_with() print_mock.assert_called_once_with([package_ahriman], log_fn=pytest.helpers.anyvar(int)) -def test_run_empty_exception(args: argparse.Namespace, configuration: Configuration, repository: Repository, - mocker: MockerFixture) -> None: +def test_perform_action_empty_exception(args: argparse.Namespace, application: Application, + mocker: MockerFixture) -> None: """ must raise ExitCode exception on empty update list """ args = _default_args(args) args.exit_code = True args.dry_run = True - mocker.patch("ahriman.core.repository.Repository.load", return_value=repository) mocker.patch("ahriman.application.application.Application.updates", return_value=[]) check_mock = mocker.patch("ahriman.application.handlers.handler.Handler.check_status") - _, repository_id = configuration.check_loaded() - Update.run(args, repository_id, configuration, report=False) + Update.perform_action(application, args) check_mock.assert_called_once_with(True, []) -def test_run_update_empty_exception(args: argparse.Namespace, package_ahriman: Package, configuration: Configuration, - repository: Repository, mocker: MockerFixture) -> None: +def test_perform_action_update_empty_exception(args: argparse.Namespace, application: Application, + package_ahriman: Package, mocker: MockerFixture) -> None: """ must raise ExitCode exception on empty build result """ args = _default_args(args) args.exit_code = True - mocker.patch("ahriman.core.repository.Repository.load", return_value=repository) mocker.patch("ahriman.application.application.Application.update", return_value=Result()) mocker.patch("ahriman.application.application.Application.updates", return_value=[package_ahriman]) mocker.patch("ahriman.application.application.Application.with_dependencies", return_value=[package_ahriman]) @@ -103,26 +112,23 @@ def test_run_update_empty_exception(args: argparse.Namespace, package_ahriman: P mocker.patch("ahriman.application.application.Application.changes") check_mock = mocker.patch("ahriman.application.handlers.handler.Handler.check_status") - _, repository_id = configuration.check_loaded() - Update.run(args, repository_id, configuration, report=False) + Update.perform_action(application, args) check_mock.assert_called_once_with(True, False) -def test_run_dry_run(args: argparse.Namespace, package_ahriman: Package, configuration: Configuration, - repository: Repository, mocker: MockerFixture) -> None: +def test_perform_action_dry_run(args: argparse.Namespace, application: Application, package_ahriman: Package, + mocker: MockerFixture) -> None: """ must run simplified command """ args = _default_args(args) args.dry_run = True - mocker.patch("ahriman.core.repository.Repository.load", return_value=repository) application_mock = mocker.patch("ahriman.application.application.Application.update") check_mock = mocker.patch("ahriman.application.handlers.handler.Handler.check_status") updates_mock = mocker.patch("ahriman.application.application.Application.updates", return_value=[package_ahriman]) changes_mock = mocker.patch("ahriman.application.application.Application.changes") - _, repository_id = configuration.check_loaded() - Update.run(args, repository_id, configuration, report=False) + Update.perform_action(application, args) updates_mock.assert_called_once_with( args.package, aur=args.aur, local=args.local, manual=args.manual, vcs=args.vcs, check_files=args.check_files) application_mock.assert_not_called() @@ -130,22 +136,19 @@ def test_run_dry_run(args: argparse.Namespace, package_ahriman: Package, configu check_mock.assert_called_once_with(False, [package_ahriman]) -def test_run_no_changes(args: argparse.Namespace, configuration: Configuration, repository: Repository, - mocker: MockerFixture) -> None: +def test_perform_action_no_changes(args: argparse.Namespace, application: Application, mocker: MockerFixture) -> None: """ must skip changes calculation """ args = _default_args(args) args.dry_run = True args.changes = False - mocker.patch("ahriman.core.repository.Repository.load", return_value=repository) mocker.patch("ahriman.application.application.Application.update") mocker.patch("ahriman.application.handlers.handler.Handler.check_status") mocker.patch("ahriman.application.application.Application.updates") changes_mock = mocker.patch("ahriman.application.application.Application.changes") - _, repository_id = configuration.check_loaded() - Update.run(args, repository_id, configuration, report=False) + Update.perform_action(application, args) changes_mock.assert_not_called() diff --git a/tests/ahriman/application/test_ahriman.py b/tests/ahriman/application/test_ahriman.py index ef3502c1..ddbe3220 100644 --- a/tests/ahriman/application/test_ahriman.py +++ b/tests/ahriman/application/test_ahriman.py @@ -271,6 +271,18 @@ def test_subparsers_package_add_option_variable_multiple(parser: argparse.Argume assert args.variable == ["var1", "var2"] +def test_subparsers_package_add_repo_update(parser: argparse.ArgumentParser) -> None: + """ + package-add must have same keys as repo-update + """ + args = parser.parse_args(["package-add", "ahriman"]) + reference_args = parser.parse_args(["repo-update"]) + del args.now + del args.source + del args.variable + assert dir(args) == dir(reference_args) + + def test_subparsers_package_archives(parser: argparse.ArgumentParser) -> None: """ package-archives command must imply action, exit code, info, lock, quiet, report and unsafe @@ -325,6 +337,26 @@ def test_subparsers_package_changes_remove_package_changes(parser: argparse.Argu assert dir(args) == dir(reference_args) +def test_subparsers_package_copy_option_architecture(parser: argparse.ArgumentParser) -> None: + """ + package-copy command must correctly parse architecture list + """ + args = parser.parse_args(["package-copy", "source", "ahriman"]) + assert args.architecture is None + args = parser.parse_args(["-a", "x86_64", "package-copy", "source", "ahriman"]) + assert args.architecture == "x86_64" + + +def test_subparsers_package_copy_option_repository(parser: argparse.ArgumentParser) -> None: + """ + package-copy command must correctly parse repository list + """ + args = parser.parse_args(["package-copy", "source", "ahriman"]) + assert args.repository is None + args = parser.parse_args(["-r", "repo", "package-copy", "source", "ahriman"]) + assert args.repository == "repo" + + def test_subparsers_package_pkgbuild(parser: argparse.ArgumentParser) -> None: """ package-pkgbuild command must imply action, exit code, lock, quiet, report and unsafe @@ -363,26 +395,6 @@ def test_subparsers_package_pkgbuild_remove_package_pkgbuild(parser: argparse.Ar assert dir(args) == dir(reference_args) -def test_subparsers_package_copy_option_architecture(parser: argparse.ArgumentParser) -> None: - """ - package-copy command must correctly parse architecture list - """ - args = parser.parse_args(["package-copy", "source", "ahriman"]) - assert args.architecture is None - args = parser.parse_args(["-a", "x86_64", "package-copy", "source", "ahriman"]) - assert args.architecture == "x86_64" - - -def test_subparsers_package_copy_option_repository(parser: argparse.ArgumentParser) -> None: - """ - package-copy command must correctly parse repository list - """ - args = parser.parse_args(["package-copy", "source", "ahriman"]) - assert args.repository is None - args = parser.parse_args(["-r", "repo", "package-copy", "source", "ahriman"]) - assert args.repository == "repo" - - def test_subparsers_package_remove_option_architecture(parser: argparse.ArgumentParser) -> None: """ package-remove command must correctly parse architecture list @@ -403,6 +415,68 @@ def test_subparsers_package_remove_option_repository(parser: argparse.ArgumentPa assert args.repository == "repo" +def test_subparsers_package_rollback(parser: argparse.ArgumentParser) -> None: + """ + package-rollback command must imply aur, changes, check-files, dependencies, dry-run, exit-code, increment, now, + local, manual, refresh, source, variable and vcs + """ + args = parser.parse_args(["package-rollback", "ahriman", "1.0.0-1"]) + assert not args.aur + assert not args.changes + assert not args.check_files + assert not args.dependencies + assert not args.dry_run + assert args.exit_code + assert not args.increment + assert not args.local + assert not args.manual + assert args.now + assert not args.refresh + assert not args.vcs + assert args.variable is None + + +def test_subparsers_package_rollback_option_architecture(parser: argparse.ArgumentParser) -> None: + """ + package-rollback command must correctly parse architecture list + """ + args = parser.parse_args(["package-rollback", "ahriman", "1.0.0-1"]) + assert args.architecture is None + args = parser.parse_args(["-a", "x86_64", "package-rollback", "ahriman", "1.0.0-1"]) + assert args.architecture == "x86_64" + + +def test_subparsers_package_rollback_option_repository(parser: argparse.ArgumentParser) -> None: + """ + package-rollback command must correctly parse repository list + """ + args = parser.parse_args(["package-rollback", "ahriman", "1.0.0-1"]) + assert args.repository is None + args = parser.parse_args(["-r", "repo", "package-rollback", "ahriman", "1.0.0-1"]) + assert args.repository == "repo" + + +def test_subparsers_package_rollback_option_hold(parser: argparse.ArgumentParser) -> None: + """ + package-rollback command must correctly parse hold option + """ + args = parser.parse_args(["package-rollback", "ahriman", "1.0.0-1"]) + assert args.hold + args = parser.parse_args(["package-rollback", "ahriman", "1.0.0-1", "--no-hold"]) + assert not args.hold + + +def test_subparsers_package_rollback_package_add(parser: argparse.ArgumentParser) -> None: + """ + package-rollback must have same keys as package-add + """ + args = parser.parse_args(["package-rollback", "ahriman", "1.0.0-1"]) + reference_args = parser.parse_args(["package-add", "ahriman"]) + del args.hold + del args.version + assert dir(args) == dir(reference_args) + + def test_subparsers_package_status(parser: argparse.ArgumentParser) -> None: """ package-status command must imply lock, quiet, report and unsafe diff --git a/tests/ahriman/core/repository/test_package_info.py b/tests/ahriman/core/repository/test_package_info.py index 7f4efb3b..7309310b 100644 --- a/tests/ahriman/core/repository/test_package_info.py +++ b/tests/ahriman/core/repository/test_package_info.py @@ -8,7 +8,6 @@ from unittest.mock import MagicMock from ahriman.core.repository import Repository from ahriman.models.changes import Changes from ahriman.models.package import Package -from ahriman.models.repository_id import RepositoryId def test_full_depends(repository: Repository, package_ahriman: Package, package_python_schedule: Package, @@ -93,18 +92,41 @@ def test_load_archives_different_version(repository: Repository, package_python_ assert packages[0].version == package_python_schedule.version +def test_load_archives_all_versions(repository: Repository, package_ahriman: Package, + mocker: MockerFixture) -> None: + """ + must load packages with different versions keeping all when latest_only is False + """ + mocker.patch("ahriman.models.package.Package.from_archive", + side_effect=[package_ahriman, replace(package_ahriman, version="0.0.1-1")]) + mocker.patch("ahriman.core.status.local_client.LocalClient.package_get", return_value=[]) + + packages = repository.load_archives([Path("a.pkg.tar.xz"), Path("b.pkg.tar.xz")], latest_only=False) + assert len(packages) == 2 + + def test_package_archives(repository: Repository, package_ahriman: Package, mocker: MockerFixture) -> None: """ must load package archives sorted by version """ - mocker.patch("ahriman.core.repository.package_info.package_like", return_value=True) - mocker.patch("pathlib.Path.iterdir", return_value=[str(i) for i in range(5)]) - mocker.patch("ahriman.models.package.Package.from_archive", - side_effect=lambda version: replace(package_ahriman, version=version)) + mocker.patch("pathlib.Path.is_dir", return_value=True) + mocker.patch("pathlib.Path.iterdir") + load_mock = mocker.patch("ahriman.core.repository.package_info.PackageInfo.load_archives", + return_value=[replace(package_ahriman, version=str(i)) for i in range(5)]) result = repository.package_archives(package_ahriman.base) assert len(result) == 5 assert [p.version for p in result] == [str(i) for i in range(5)] + load_mock.assert_called_once_with(pytest.helpers.anyvar(int), latest_only=False) + + +def test_package_archives_no_directory(repository: Repository, package_ahriman: Package, + mocker: MockerFixture) -> None: + """ + must return empty list if archive directory does not exist + """ + mocker.patch("pathlib.Path.is_dir", return_value=False) + assert repository.package_archives(package_ahriman.base) == [] def test_package_archives_architecture_mismatch(repository: Repository, package_ahriman: Package, @@ -114,8 +136,10 @@ def test_package_archives_architecture_mismatch(repository: Repository, package_ """ package_ahriman.packages[package_ahriman.base].architecture = "i686" - mocker.patch("pathlib.Path.iterdir", return_value=[package_ahriman.packages[package_ahriman.base].filepath]) - mocker.patch("ahriman.models.package.Package.from_archive", return_value=package_ahriman) + mocker.patch("pathlib.Path.is_dir", return_value=True) + mocker.patch("pathlib.Path.iterdir") + mocker.patch("ahriman.core.repository.package_info.PackageInfo.load_archives", + return_value=[package_ahriman]) result = repository.package_archives(package_ahriman.base) assert len(result) == 0 @@ -126,13 +150,7 @@ def test_package_archives_lookup(repository: Repository, package_ahriman: Packag """ must existing packages which match the version """ - mocker.patch("pathlib.Path.is_dir", return_value=True) - mocker.patch("pathlib.Path.iterdir", return_value=[ - Path("1.pkg.tar.zst"), - Path("2.pkg.tar.zst"), - Path("3.pkg.tar.zst"), - ]) - mocker.patch("ahriman.models.package.Package.from_archive", side_effect=[ + archives_mock = mocker.patch("ahriman.core.repository.package_info.PackageInfo.package_archives", return_value=[ package_ahriman, package_python_schedule, replace(package_ahriman, version="1"), @@ -140,6 +158,7 @@ def test_package_archives_lookup(repository: Repository, package_ahriman: Packag glob_mock = mocker.patch("pathlib.Path.glob", return_value=[Path("1.pkg.tar.xz")]) assert repository.package_archives_lookup(package_ahriman) == [Path("1.pkg.tar.xz")] + archives_mock.assert_called_once_with(package_ahriman.base) glob_mock.assert_called_once_with(f"{package_ahriman.packages[package_ahriman.base].filename}*") @@ -148,12 +167,8 @@ def test_package_archives_lookup_version_mismatch(repository: Repository, packag """ must return nothing if no packages found with the same version """ - mocker.patch("pathlib.Path.is_dir", return_value=True) - mocker.patch("pathlib.Path.iterdir", return_value=[ - Path("1.pkg.tar.zst"), - ]) - mocker.patch("ahriman.models.package.Package.from_archive", return_value=replace(package_ahriman, version="1")) - + mocker.patch("ahriman.core.repository.package_info.PackageInfo.package_archives", + return_value=[replace(package_ahriman, version="1")]) assert repository.package_archives_lookup(package_ahriman) == [] @@ -162,14 +177,7 @@ def test_package_archives_lookup_architecture_mismatch(repository: Repository, p """ must return nothing if architecture doesn't match """ - package_ahriman.packages[package_ahriman.base].architecture = "x86_64" - mocker.patch("pathlib.Path.is_dir", return_value=True) - repository.repository_id = RepositoryId("i686", repository.repository_id.name) - mocker.patch("pathlib.Path.iterdir", return_value=[ - Path("1.pkg.tar.zst"), - ]) - mocker.patch("ahriman.models.package.Package.from_archive", return_value=package_ahriman) - + mocker.patch("ahriman.core.repository.package_info.PackageInfo.package_archives", return_value=[]) assert repository.package_archives_lookup(package_ahriman) == [] @@ -178,7 +186,7 @@ def test_package_archives_lookup_no_archive_directory(repository: Repository, pa """ must return nothing if no archive directory found """ - mocker.patch("pathlib.Path.is_dir", return_value=False) + mocker.patch("ahriman.core.repository.package_info.PackageInfo.package_archives", return_value=[]) assert repository.package_archives_lookup(package_ahriman) == [] diff --git a/tests/ahriman/core/test_spawn.py b/tests/ahriman/core/test_spawn.py index 40377faf..531034dd 100644 --- a/tests/ahriman/core/test_spawn.py +++ b/tests/ahriman/core/test_spawn.py @@ -196,6 +196,26 @@ def test_packages_remove(spawner: Spawn, repository_id: RepositoryId, mocker: Mo spawn_mock.assert_called_once_with(repository_id, "package-remove", "ahriman", "linux") +def test_packages_rollback(spawner: Spawn, repository_id: RepositoryId, mocker: MockerFixture) -> None: + """ + must call package rollback + """ + spawn_mock = mocker.patch("ahriman.core.spawn.Spawn._spawn_process") + assert spawner.packages_rollback(repository_id, "ahriman", "1.0.0-1", "packager", hold=False) + spawn_mock.assert_called_once_with(repository_id, "package-rollback", "ahriman", "1.0.0-1", + **{"username": "packager", "no-hold": ""}) + + +def test_packages_rollback_with_hold(spawner: Spawn, repository_id: RepositoryId, mocker: MockerFixture) -> None: + """ + must call package rollback with hold + """ + spawn_mock = mocker.patch("ahriman.core.spawn.Spawn._spawn_process") + assert spawner.packages_rollback(repository_id, "ahriman", "1.0.0-1", "packager", hold=True) + spawn_mock.assert_called_once_with(repository_id, "package-rollback", "ahriman", "1.0.0-1", + **{"username": "packager", "hold": ""}) + + def test_packages_update(spawner: Spawn, repository_id: RepositoryId, mocker: MockerFixture) -> None: """ must call repo update diff --git a/tests/ahriman/web/schemas/test_packager_schema.py b/tests/ahriman/web/schemas/test_packager_schema.py new file mode 100644 index 00000000..1982fb6b --- /dev/null +++ b/tests/ahriman/web/schemas/test_packager_schema.py @@ -0,0 +1 @@ +# schema testing goes in view class tests diff --git a/tests/ahriman/web/schemas/test_rollback_schema.py b/tests/ahriman/web/schemas/test_rollback_schema.py new file mode 100644 index 00000000..1982fb6b --- /dev/null +++ b/tests/ahriman/web/schemas/test_rollback_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_rollback.py b/tests/ahriman/web/views/v1/service/test_view_v1_service_rollback.py new file mode 100644 index 00000000..d6d9156d --- /dev/null +++ b/tests/ahriman/web/views/v1/service/test_view_v1_service_rollback.py @@ -0,0 +1,70 @@ +import pytest + +from aiohttp.test_utils import TestClient +from pytest_mock import MockerFixture +from unittest.mock import AsyncMock + +from ahriman.models.repository_id import RepositoryId +from ahriman.models.user_access import UserAccess +from ahriman.web.views.v1.service.rollback import RollbackView + + +async def test_get_permission() -> None: + """ + must return correct permission for the request + """ + for method in ("POST",): + request = pytest.helpers.request("", "", method) + assert await RollbackView.get_permission(request) == UserAccess.Full + + +def test_routes() -> None: + """ + must return correct routes + """ + assert RollbackView.ROUTES == ["/api/v1/service/rollback"] + + +async def test_post(client: TestClient, repository_id: RepositoryId, mocker: MockerFixture) -> None: + """ + must call post request correctly + """ + rollback_mock = mocker.patch("ahriman.core.spawn.Spawn.packages_rollback", return_value="abc") + user_mock = AsyncMock() + user_mock.return_value = "username" + mocker.patch("ahriman.web.views.base.BaseView.username", side_effect=user_mock) + request_schema = pytest.helpers.schema_request(RollbackView.post) + response_schema = pytest.helpers.schema_response(RollbackView.post) + + payload = {"package": "ahriman", "version": "version"} + assert not request_schema.validate(payload) + response = await client.post("/api/v1/service/rollback", json=payload) + assert response.ok + rollback_mock.assert_called_once_with(repository_id, "ahriman", "version", "username", hold=True) + + json = await response.json() + assert json["process_id"] == "abc" + assert not response_schema.validate(json) + + +async def test_post_empty(client: TestClient, mocker: MockerFixture) -> None: + """ + must call raise 400 on empty request + """ + rollback_mock = mocker.patch("ahriman.core.spawn.Spawn.packages_rollback") + response_schema = pytest.helpers.schema_response(RollbackView.post, code=400) + + response = await client.post("/api/v1/service/rollback", json={"package": "", "version": "version"}) + assert response.status == 400 + assert not response_schema.validate(await response.json()) + rollback_mock.assert_not_called() + + response = await client.post("/api/v1/service/rollback", json={"package": "ahriman", "version": ""}) + assert response.status == 400 + assert not response_schema.validate(await response.json()) + rollback_mock.assert_not_called() + + response = await client.post("/api/v1/service/rollback", json={}) + assert response.status == 400 + assert not response_schema.validate(await response.json()) + rollback_mock.assert_not_called()