diff --git a/package/etc/ahriman.ini.d/logging.ini b/package/etc/ahriman.ini.d/logging.ini index e2e9a495..b4c9fa13 100644 --- a/package/etc/ahriman.ini.d/logging.ini +++ b/package/etc/ahriman.ini.d/logging.ini @@ -2,7 +2,7 @@ keys = root,builder,build_details,http [handlers] -keys = console_handler,build_file_handler,file_handler,http_handler,syslog_handler +keys = console_handler,syslog_handler [formatters] keys = generic_format,syslog_format @@ -13,24 +13,6 @@ level = DEBUG formatter = generic_format args = (sys.stderr,) -[handler_file_handler] -class = logging.handlers.RotatingFileHandler -level = DEBUG -formatter = generic_format -args = ("/var/log/ahriman/ahriman.log", "a", 20971520, 20) - -[handler_build_file_handler] -class = logging.handlers.RotatingFileHandler -level = DEBUG -formatter = generic_format -args = ("/var/log/ahriman/build.log", "a", 20971520, 20) - -[handler_http_handler] -class = logging.handlers.RotatingFileHandler -level = DEBUG -formatter = generic_format -args = ("/var/log/ahriman/http.log", "a", 20971520, 20) - [handler_syslog_handler] class = logging.handlers.SysLogHandler level = DEBUG diff --git a/package/share/ahriman/build-status.jinja2 b/package/share/ahriman/build-status.jinja2 index e9db6106..35520e01 100644 --- a/package/share/ahriman/build-status.jinja2 +++ b/package/share/ahriman/build-status.jinja2 @@ -5,11 +5,7 @@ - - - - - {% include "style.jinja2" %} + {% include "utils/style.jinja2" %} @@ -26,20 +22,46 @@
- + {% if not auth_enabled or auth_username is not none %} + + + + {% endif %} + + +
+ data-show-fullscreen="true" + data-show-search-clear-button="true" + data-sortable="true" + data-sort-reset="true" + data-toggle="table" + data-toolbar="#toolbar"> - - + + + + + @@ -48,10 +70,13 @@ {% if authorized %} {% for package in packages %} - + + - + + + @@ -77,45 +102,23 @@ {% if auth_username is none %} {% else %} - - + + {% endif %} {% endif %} - + {% if auth_enabled %} + {% include "build-status/login-modal.jinja2" %} + {% endif %} - - - - - + {% include "build-status/package-actions-modals.jinja2" %} + + {% include "utils/bootstrap-scripts.jinja2" %} + + {% include "build-status/package-actions-script.jinja2" %} diff --git a/package/share/ahriman/build-status/login-modal.jinja2 b/package/share/ahriman/build-status/login-modal.jinja2 new file mode 100644 index 00000000..26f25f8e --- /dev/null +++ b/package/share/ahriman/build-status/login-modal.jinja2 @@ -0,0 +1,29 @@ + \ No newline at end of file diff --git a/package/share/ahriman/build-status/package-actions-modals.jinja2 b/package/share/ahriman/build-status/package-actions-modals.jinja2 new file mode 100644 index 00000000..7faa73f1 --- /dev/null +++ b/package/share/ahriman/build-status/package-actions-modals.jinja2 @@ -0,0 +1,57 @@ + + + + + \ No newline at end of file diff --git a/package/share/ahriman/build-status/package-actions-script.jinja2 b/package/share/ahriman/build-status/package-actions-script.jinja2 new file mode 100644 index 00000000..3b77b5e1 --- /dev/null +++ b/package/share/ahriman/build-status/package-actions-script.jinja2 @@ -0,0 +1,76 @@ + \ No newline at end of file diff --git a/package/share/ahriman/email-index.jinja2 b/package/share/ahriman/email-index.jinja2 index 87d3fd3e..1fc99d32 100644 --- a/package/share/ahriman/email-index.jinja2 +++ b/package/share/ahriman/email-index.jinja2 @@ -6,15 +6,13 @@ - - - {% include "style.jinja2" %} + {% include "utils/style.jinja2" %}
-
package basepackagespackage base versionpackagesgroupslicenses last update status
{{ package.base }}{{ package.packages|join("
"|safe) }}
{{ package.version }}{{ package.packages|join("
"|safe) }}
{{ package.groups|join("
"|safe) }}
{{ package.licenses|join("
"|safe) }}
{{ package.timestamp }} {{ package.status }}
+
diff --git a/package/share/ahriman/repo-index.jinja2 b/package/share/ahriman/repo-index.jinja2 index e3b1ea13..c1a85691 100644 --- a/package/share/ahriman/repo-index.jinja2 +++ b/package/share/ahriman/repo-index.jinja2 @@ -5,11 +5,7 @@ - - - - - {% include "style.jinja2" %} + {% include "utils/style.jinja2" %} @@ -30,19 +26,32 @@ SigLevel = Database{% if has_repo_signed %}Required{% else %}Never{% endif %} Pa
-
package
+ data-show-fullscreen="true" + data-show-search-clear-button="true" + data-sortable="true" + data-sort-reset="true" + data-toggle="table"> - + + + + + + + @@ -54,6 +63,12 @@ SigLevel = Database{% if has_repo_signed %}Required{% else %}Never{% endif %} Pa + + + + + + @@ -73,11 +88,7 @@ SigLevel = Database{% if has_repo_signed %}Required{% else %}Never{% endif %} Pa - - - - - + {% include "utils/bootstrap-scripts.jinja2" %} diff --git a/package/share/ahriman/style.jinja2 b/package/share/ahriman/style.jinja2 deleted file mode 100644 index 6f29e9e9..00000000 --- a/package/share/ahriman/style.jinja2 +++ /dev/null @@ -1 +0,0 @@ - diff --git a/package/share/ahriman/utils/bootstrap-scripts.jinja2 b/package/share/ahriman/utils/bootstrap-scripts.jinja2 new file mode 100644 index 00000000..59991111 --- /dev/null +++ b/package/share/ahriman/utils/bootstrap-scripts.jinja2 @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/package/share/ahriman/utils/style.jinja2 b/package/share/ahriman/utils/style.jinja2 new file mode 100644 index 00000000..b2ccd979 --- /dev/null +++ b/package/share/ahriman/utils/style.jinja2 @@ -0,0 +1,9 @@ + + + + + + + + diff --git a/setup.py b/setup.py index 430ea257..a1ec1fef 100644 --- a/setup.py +++ b/setup.py @@ -68,7 +68,15 @@ setup( "package/share/ahriman/build-status.jinja2", "package/share/ahriman/email-index.jinja2", "package/share/ahriman/repo-index.jinja2", - "package/share/ahriman/style.jinja2", + ]), + ("share/ahriman/build-status", [ + "package/share/ahriman/build-status/login-modal.jinja2", + "package/share/ahriman/build-status/package-actions-modals.jinja2", + "package/share/ahriman/build-status/package-actions-script.jinja2", + ]), + ("share/ahriman/utils", [ + "package/share/ahriman/utils/bootstrap-scripts.jinja2", + "package/share/ahriman/utils/style.jinja2", ]), ], diff --git a/src/ahriman/application/ahriman.py b/src/ahriman/application/ahriman.py index 75480161..c9be419b 100644 --- a/src/ahriman/application/ahriman.py +++ b/src/ahriman/application/ahriman.py @@ -27,11 +27,10 @@ from ahriman import version from ahriman.application import handlers from ahriman.models.build_status import BuildStatusEnum from ahriman.models.sign_settings import SignSettings +from ahriman.models.user_access import UserAccess # pylint thinks it is bad idea, but get the fuck off -from ahriman.models.user_access import UserAccess - SubParserAction = argparse._SubParsersAction # pylint: disable=protected-access @@ -367,7 +366,7 @@ def _set_web_parser(root: SubParserAction) -> argparse.ArgumentParser: """ parser = root.add_parser("web", help="start web server", description="start web server", formatter_class=argparse.ArgumentDefaultsHelpFormatter) - parser.set_defaults(handler=handlers.Web, lock=None, no_report=True) + parser.set_defaults(handler=handlers.Web, lock=None, no_report=True, parser=_parser) return parser diff --git a/src/ahriman/application/application.py b/src/ahriman/application/application.py index 277fe884..0f9f9edd 100644 --- a/src/ahriman/application/application.py +++ b/src/ahriman/application/application.py @@ -40,16 +40,17 @@ class Application: :ivar repository: repository instance """ - def __init__(self, architecture: str, configuration: Configuration) -> None: + def __init__(self, architecture: str, configuration: Configuration, no_report: bool) -> None: """ default constructor :param architecture: repository architecture :param configuration: configuration instance + :param no_report: force disable reporting """ self.logger = logging.getLogger("root") self.configuration = configuration self.architecture = architecture - self.repository = Repository(architecture, configuration) + self.repository = Repository(architecture, configuration, no_report) def _finalize(self, built_packages: Iterable[Package]) -> None: """ diff --git a/src/ahriman/application/handlers/add.py b/src/ahriman/application/handlers/add.py index 4af8de55..34409de9 100644 --- a/src/ahriman/application/handlers/add.py +++ b/src/ahriman/application/handlers/add.py @@ -32,14 +32,16 @@ class Add(Handler): """ @classmethod - def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, configuration: Configuration) -> None: + def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, + configuration: Configuration, no_report: bool) -> None: """ callback for command line :param args: command line args :param architecture: repository architecture :param configuration: configuration instance + :param no_report: force disable reporting """ - application = Application(architecture, configuration) + application = Application(architecture, configuration, no_report) application.add(args.package, args.without_dependencies) if not args.now: return diff --git a/src/ahriman/application/handlers/clean.py b/src/ahriman/application/handlers/clean.py index 8454ce91..9d6b8be9 100644 --- a/src/ahriman/application/handlers/clean.py +++ b/src/ahriman/application/handlers/clean.py @@ -32,12 +32,14 @@ class Clean(Handler): """ @classmethod - def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, configuration: Configuration) -> None: + def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, + configuration: Configuration, no_report: bool) -> None: """ callback for command line :param args: command line args :param architecture: repository architecture :param configuration: configuration instance + :param no_report: force disable reporting """ - Application(architecture, configuration).clean(args.no_build, args.no_cache, args.no_chroot, - args.no_manual, args.no_packages) + Application(architecture, configuration, no_report).clean(args.no_build, args.no_cache, args.no_chroot, + args.no_manual, args.no_packages) diff --git a/src/ahriman/application/handlers/create_user.py b/src/ahriman/application/handlers/create_user.py index 6fc9aca6..4be5c92a 100644 --- a/src/ahriman/application/handlers/create_user.py +++ b/src/ahriman/application/handlers/create_user.py @@ -34,12 +34,14 @@ class CreateUser(Handler): """ @classmethod - def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, configuration: Configuration) -> None: + def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, + configuration: Configuration, no_report: bool) -> None: """ callback for command line :param args: command line args :param architecture: repository architecture :param configuration: configuration instance + :param no_report: force disable reporting """ salt = CreateUser.get_salt(configuration) user = CreateUser.create_user(args) diff --git a/src/ahriman/application/handlers/dump.py b/src/ahriman/application/handlers/dump.py index 8a9151c8..c5f3a692 100644 --- a/src/ahriman/application/handlers/dump.py +++ b/src/ahriman/application/handlers/dump.py @@ -33,12 +33,14 @@ class Dump(Handler): _print = print @classmethod - def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, configuration: Configuration) -> None: + def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, + configuration: Configuration, no_report: bool) -> None: """ callback for command line :param args: command line args :param architecture: repository architecture :param configuration: configuration instance + :param no_report: force disable reporting """ dump = configuration.dump() for section, values in sorted(dump.items()): diff --git a/src/ahriman/application/handlers/handler.py b/src/ahriman/application/handlers/handler.py index 23a582ba..85bd88ad 100644 --- a/src/ahriman/application/handlers/handler.py +++ b/src/ahriman/application/handlers/handler.py @@ -27,17 +27,20 @@ from typing import Set, Type from ahriman.application.lock import Lock from ahriman.core.configuration import Configuration -from ahriman.core.exceptions import MissingArchitecture +from ahriman.core.exceptions import MissingArchitecture, MultipleArchitecture from ahriman.models.repository_paths import RepositoryPaths class Handler: """ base handler class for command callbacks + :cvar ALLOW_MULTI_ARCHITECTURE_RUN: allow to run with multiple architectures """ + ALLOW_MULTI_ARCHITECTURE_RUN = True + @classmethod - def _call(cls: Type[Handler], args: argparse.Namespace, architecture: str) -> bool: + def call(cls: Type[Handler], args: argparse.Namespace, architecture: str) -> bool: """ additional function to wrap all calls for multiprocessing library :param args: command line args @@ -47,7 +50,7 @@ class Handler: try: configuration = Configuration.from_path(args.configuration, architecture, not args.no_log) with Lock(args, architecture, configuration): - cls.run(args, architecture, configuration) + cls.run(args, architecture, configuration, args.no_report) return True except Exception: logging.getLogger("root").exception("process exception") @@ -61,9 +64,18 @@ class Handler: :return: 0 on success, 1 otherwise """ architectures = cls.extract_architectures(args) - with Pool(len(architectures)) as pool: - result = pool.starmap( - cls._call, [(args, architecture) for architecture in architectures]) + + # actually we do not have to spawn another process if it is single-process application, do we? + if len(architectures) > 1: + if not cls.ALLOW_MULTI_ARCHITECTURE_RUN: + raise MultipleArchitecture(args.command) + + with Pool(len(architectures)) as pool: + result = pool.starmap( + cls.call, [(args, architecture) for architecture in architectures]) + else: + result = [cls.call(args, architectures.pop())] + return 0 if all(result) else 1 @classmethod @@ -88,11 +100,13 @@ class Handler: return architectures @classmethod - def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, configuration: Configuration) -> None: + def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, + configuration: Configuration, no_report: bool) -> None: """ callback for command line :param args: command line args :param architecture: repository architecture :param configuration: configuration instance + :param no_report: force disable reporting """ raise NotImplementedError diff --git a/src/ahriman/application/handlers/init.py b/src/ahriman/application/handlers/init.py index beed55da..7d58c252 100644 --- a/src/ahriman/application/handlers/init.py +++ b/src/ahriman/application/handlers/init.py @@ -32,11 +32,13 @@ class Init(Handler): """ @classmethod - def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, configuration: Configuration) -> None: + def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, + configuration: Configuration, no_report: bool) -> None: """ callback for command line :param args: command line args :param architecture: repository architecture :param configuration: configuration instance + :param no_report: force disable reporting """ - Application(architecture, configuration).repository.repo.init() + Application(architecture, configuration, no_report).repository.repo.init() diff --git a/src/ahriman/application/handlers/key_import.py b/src/ahriman/application/handlers/key_import.py index 9b6b0216..18754a1e 100644 --- a/src/ahriman/application/handlers/key_import.py +++ b/src/ahriman/application/handlers/key_import.py @@ -32,11 +32,13 @@ class KeyImport(Handler): """ @classmethod - def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, configuration: Configuration) -> None: + def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, + configuration: Configuration, no_report: bool) -> None: """ callback for command line :param args: command line args :param architecture: repository architecture :param configuration: configuration instance + :param no_report: force disable reporting """ - Application(architecture, configuration).repository.sign.import_key(args.key_server, args.key) + Application(architecture, configuration, no_report).repository.sign.import_key(args.key_server, args.key) diff --git a/src/ahriman/application/handlers/rebuild.py b/src/ahriman/application/handlers/rebuild.py index e6613782..b318964a 100644 --- a/src/ahriman/application/handlers/rebuild.py +++ b/src/ahriman/application/handlers/rebuild.py @@ -32,16 +32,18 @@ class Rebuild(Handler): """ @classmethod - def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, configuration: Configuration) -> None: + def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, + configuration: Configuration, no_report: bool) -> None: """ callback for command line :param args: command line args :param architecture: repository architecture :param configuration: configuration instance + :param no_report: force disable reporting """ depends_on = set(args.depends_on) if args.depends_on else None - application = Application(architecture, configuration) + application = Application(architecture, configuration, no_report) packages = [ package for package in application.repository.packages() diff --git a/src/ahriman/application/handlers/remove.py b/src/ahriman/application/handlers/remove.py index ba570095..7a403312 100644 --- a/src/ahriman/application/handlers/remove.py +++ b/src/ahriman/application/handlers/remove.py @@ -32,11 +32,13 @@ class Remove(Handler): """ @classmethod - def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, configuration: Configuration) -> None: + def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, + configuration: Configuration, no_report: bool) -> None: """ callback for command line :param args: command line args :param architecture: repository architecture :param configuration: configuration instance + :param no_report: force disable reporting """ - Application(architecture, configuration).remove(args.package) + Application(architecture, configuration, no_report).remove(args.package) diff --git a/src/ahriman/application/handlers/remove_unknown.py b/src/ahriman/application/handlers/remove_unknown.py index 1e19f79c..39cc2a60 100644 --- a/src/ahriman/application/handlers/remove_unknown.py +++ b/src/ahriman/application/handlers/remove_unknown.py @@ -33,14 +33,16 @@ class RemoveUnknown(Handler): """ @classmethod - def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, configuration: Configuration) -> None: + def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, + configuration: Configuration, no_report: bool) -> None: """ callback for command line :param args: command line args :param architecture: repository architecture :param configuration: configuration instance + :param no_report: force disable reporting """ - application = Application(architecture, configuration) + application = Application(architecture, configuration, no_report) unknown_packages = application.unknown() if args.dry_run: for package in unknown_packages: diff --git a/src/ahriman/application/handlers/report.py b/src/ahriman/application/handlers/report.py index 79ce3c07..6bbb8bac 100644 --- a/src/ahriman/application/handlers/report.py +++ b/src/ahriman/application/handlers/report.py @@ -32,11 +32,13 @@ class Report(Handler): """ @classmethod - def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, configuration: Configuration) -> None: + def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, + configuration: Configuration, no_report: bool) -> None: """ callback for command line :param args: command line args :param architecture: repository architecture :param configuration: configuration instance + :param no_report: force disable reporting """ - Application(architecture, configuration).report(args.target, []) + Application(architecture, configuration, no_report).report(args.target, []) diff --git a/src/ahriman/application/handlers/search.py b/src/ahriman/application/handlers/search.py index ba026137..ce08a5e0 100644 --- a/src/ahriman/application/handlers/search.py +++ b/src/ahriman/application/handlers/search.py @@ -32,12 +32,14 @@ class Search(Handler): """ @classmethod - def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, configuration: Configuration) -> None: + def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, + configuration: Configuration, no_report: bool) -> None: """ callback for command line :param args: command line args :param architecture: repository architecture :param configuration: configuration instance + :param no_report: force disable reporting """ search = " ".join(args.search) packages = aur.search(search) diff --git a/src/ahriman/application/handlers/setup.py b/src/ahriman/application/handlers/setup.py index 34121b2e..d2ac8d25 100644 --- a/src/ahriman/application/handlers/setup.py +++ b/src/ahriman/application/handlers/setup.py @@ -43,14 +43,16 @@ class Setup(Handler): SUDOERS_PATH = Path("/etc/sudoers.d/ahriman") @classmethod - def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, configuration: Configuration) -> None: + def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, + configuration: Configuration, no_report: bool) -> None: """ callback for command line :param args: command line args :param architecture: repository architecture :param configuration: configuration instance + :param no_report: force disable reporting """ - application = Application(architecture, configuration) + application = Application(architecture, configuration, no_report) Setup.create_makepkg_configuration(args.packager, application.repository.paths) Setup.create_executable(args.build_command, architecture) Setup.create_devtools_configuration(args.build_command, architecture, args.from_configuration, diff --git a/src/ahriman/application/handlers/sign.py b/src/ahriman/application/handlers/sign.py index c5ed285e..66d1b957 100644 --- a/src/ahriman/application/handlers/sign.py +++ b/src/ahriman/application/handlers/sign.py @@ -32,11 +32,13 @@ class Sign(Handler): """ @classmethod - def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, configuration: Configuration) -> None: + def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, + configuration: Configuration, no_report: bool) -> None: """ callback for command line :param args: command line args :param architecture: repository architecture :param configuration: configuration instance + :param no_report: force disable reporting """ - Application(architecture, configuration).sign(args.package) + Application(architecture, configuration, no_report).sign(args.package) diff --git a/src/ahriman/application/handlers/status.py b/src/ahriman/application/handlers/status.py index ab790ae4..66e581b1 100644 --- a/src/ahriman/application/handlers/status.py +++ b/src/ahriman/application/handlers/status.py @@ -34,24 +34,27 @@ class Status(Handler): """ @classmethod - def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, configuration: Configuration) -> None: + def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, + configuration: Configuration, no_report: bool) -> None: """ callback for command line :param args: command line args :param architecture: repository architecture :param configuration: configuration instance + :param no_report: force disable reporting """ - application = Application(architecture, configuration) + # we are using reporter here + client = Application(architecture, configuration, no_report=False).repository.reporter if args.ahriman: - ahriman = application.repository.reporter.get_self() + ahriman = client.get_self() print(ahriman.pretty_print()) print() if args.package: packages: Iterable[Tuple[Package, BuildStatus]] = sum( - [application.repository.reporter.get(base) for base in args.package], + [client.get(base) for base in args.package], start=[]) else: - packages = application.repository.reporter.get(None) + packages = client.get(None) for package, package_status in sorted(packages, key=lambda item: item[0].base): print(package.pretty_print()) print(f"\t{package.version}") diff --git a/src/ahriman/application/handlers/status_update.py b/src/ahriman/application/handlers/status_update.py index 39647dc0..1b80ded4 100644 --- a/src/ahriman/application/handlers/status_update.py +++ b/src/ahriman/application/handlers/status_update.py @@ -32,14 +32,17 @@ class StatusUpdate(Handler): """ @classmethod - def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, configuration: Configuration) -> None: + def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, + configuration: Configuration, no_report: bool) -> None: """ callback for command line :param args: command line args :param architecture: repository architecture :param configuration: configuration instance + :param no_report: force disable reporting """ - client = Application(architecture, configuration).repository.reporter + # we are using reporter here + client = Application(architecture, configuration, no_report=False).repository.reporter callback: Callable[[str], None] = lambda p: client.remove(p) if args.remove else client.update(p, args.status) if args.package: # update packages statuses diff --git a/src/ahriman/application/handlers/sync.py b/src/ahriman/application/handlers/sync.py index bedd0416..51af527e 100644 --- a/src/ahriman/application/handlers/sync.py +++ b/src/ahriman/application/handlers/sync.py @@ -32,11 +32,13 @@ class Sync(Handler): """ @classmethod - def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, configuration: Configuration) -> None: + def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, + configuration: Configuration, no_report: bool) -> None: """ callback for command line :param args: command line args :param architecture: repository architecture :param configuration: configuration instance + :param no_report: force disable reporting """ - Application(architecture, configuration).sync(args.target, []) + Application(architecture, configuration, no_report).sync(args.target, []) diff --git a/src/ahriman/application/handlers/update.py b/src/ahriman/application/handlers/update.py index 08febe66..c9bb3fab 100644 --- a/src/ahriman/application/handlers/update.py +++ b/src/ahriman/application/handlers/update.py @@ -32,14 +32,16 @@ class Update(Handler): """ @classmethod - def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, configuration: Configuration) -> None: + def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, + configuration: Configuration, no_report: bool) -> None: """ callback for command line :param args: command line args :param architecture: repository architecture :param configuration: configuration instance + :param no_report: force disable reporting """ - application = Application(architecture, configuration) + application = Application(architecture, configuration, no_report) packages = application.get_updates(args.package, args.no_aur, args.no_manual, args.no_vcs, Update.log_fn(application, args.dry_run)) if args.dry_run: diff --git a/src/ahriman/application/handlers/web.py b/src/ahriman/application/handlers/web.py index 837dce4e..29c6524d 100644 --- a/src/ahriman/application/handlers/web.py +++ b/src/ahriman/application/handlers/web.py @@ -23,6 +23,7 @@ from typing import Type from ahriman.application.handlers.handler import Handler from ahriman.core.configuration import Configuration +from ahriman.core.spawn import Spawn class Web(Handler): @@ -30,14 +31,23 @@ class Web(Handler): web server handler """ + ALLOW_MULTI_ARCHITECTURE_RUN = False # required to be able to spawn external processes + @classmethod - def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, configuration: Configuration) -> None: + def run(cls: Type[Handler], args: argparse.Namespace, architecture: str, + configuration: Configuration, no_report: bool) -> None: """ callback for command line :param args: command line args :param architecture: repository architecture :param configuration: configuration instance + :param no_report: force disable reporting """ + # we are using local import for optional dependencies from ahriman.web.web import run_server, setup_service - application = setup_service(architecture, configuration) + + spawner = Spawn(args.parser(), architecture, configuration) + spawner.start() + + application = setup_service(architecture, configuration, spawner) run_server(application) diff --git a/src/ahriman/core/auth/auth.py b/src/ahriman/core/auth/auth.py index 6ff0a8b8..70de402e 100644 --- a/src/ahriman/core/auth/auth.py +++ b/src/ahriman/core/auth/auth.py @@ -19,7 +19,7 @@ # from __future__ import annotations -from typing import Optional, Set, Type +from typing import Optional, Type from ahriman.core.configuration import Configuration from ahriman.models.auth_settings import AuthSettings @@ -36,8 +36,8 @@ class Auth: :cvar ALLOWED_PATHS_GROUPS: URI paths prefixes which can be accessed without authorization, predefined """ - ALLOWED_PATHS = {"/", "/favicon.ico", "/index.html", "/login", "/logout"} - ALLOWED_PATHS_GROUPS: Set[str] = set() + ALLOWED_PATHS = {"/", "/favicon.ico", "/index.html"} + ALLOWED_PATHS_GROUPS = {"/user-api"} def __init__(self, configuration: Configuration, provider: AuthSettings = AuthSettings.Disabled) -> None: """ diff --git a/src/ahriman/core/exceptions.py b/src/ahriman/core/exceptions.py index 1e452517..10a09989 100644 --- a/src/ahriman/core/exceptions.py +++ b/src/ahriman/core/exceptions.py @@ -109,6 +109,19 @@ class MissingArchitecture(Exception): Exception.__init__(self, f"Architecture required for subcommand {command}, but missing") +class MultipleArchitecture(Exception): + """ + exception which will be raised if multiple architectures are not supported by the handler + """ + + def __init__(self, command: str) -> None: + """ + default constructor + :param command: command name which throws exception + """ + Exception.__init__(self, f"Multiple architectures are not supported by subcommand {command}") + + class ReportFailed(Exception): """ report generation exception diff --git a/src/ahriman/core/repository/properties.py b/src/ahriman/core/repository/properties.py index e2b040b2..54e9ebc2 100644 --- a/src/ahriman/core/repository/properties.py +++ b/src/ahriman/core/repository/properties.py @@ -43,7 +43,13 @@ class Properties: :ivar sign: GPG wrapper instance """ - def __init__(self, architecture: str, configuration: Configuration) -> None: + def __init__(self, architecture: str, configuration: Configuration, no_report: bool) -> None: + """ + default constructor + :param architecture: repository architecture + :param configuration: configuration instance + :param no_report: force disable reporting + """ self.logger = logging.getLogger("builder") self.architecture = architecture self.configuration = configuration @@ -58,4 +64,4 @@ class Properties: self.pacman = Pacman(configuration) self.sign = GPG(architecture, configuration) self.repo = Repo(self.name, self.paths, self.sign.repository_sign_args) - self.reporter = Client.load(configuration) + self.reporter = Client() if no_report else Client.load(configuration) diff --git a/src/ahriman/core/spawn.py b/src/ahriman/core/spawn.py new file mode 100644 index 00000000..eb094d54 --- /dev/null +++ b/src/ahriman/core/spawn.py @@ -0,0 +1,137 @@ +# +# Copyright (c) 2021 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 __future__ import annotations + +import argparse +import logging +import uuid + +from multiprocessing import Process, Queue +from threading import Lock, Thread +from typing import Callable, Dict, Iterable, Tuple + +from ahriman.core.configuration import Configuration + + +class Spawn(Thread): + """ + helper to spawn external ahriman process + MUST NOT be used directly, the only one usage allowed is to spawn process from web services + :ivar active: map of active child processes required to avoid zombies + :ivar architecture: repository architecture + :ivar configuration: configuration instance + :ivar logger: spawner logger + :ivar queue: multiprocessing queue to read updates from processes + """ + + def __init__(self, args_parser: argparse.ArgumentParser, architecture: str, configuration: Configuration) -> None: + """ + default constructor + :param args_parser: command line parser for the application + :param architecture: repository architecture + :param configuration: configuration instance + """ + Thread.__init__(self, name="spawn") + self.architecture = architecture + self.args_parser = args_parser + self.configuration = configuration + self.logger = logging.getLogger("http") + + self.lock = Lock() + self.active: Dict[str, Process] = {} + # stupid pylint does not know that it is possible + self.queue: Queue[Tuple[str, bool]] = Queue() # pylint: disable=unsubscriptable-object + + @staticmethod + def process(callback: Callable[[argparse.Namespace, str], bool], args: argparse.Namespace, architecture: str, + process_id: str, queue: Queue[Tuple[str, bool]]) -> None: # pylint: disable=unsubscriptable-object + """ + helper to run external process + :param callback: application run function (i.e. Handler.run method) + :param args: command line arguments + :param architecture: repository architecture + :param process_id: process unique identifier + :param queue: output queue + """ + result = callback(args, architecture) + queue.put((process_id, result)) + + def packages_add(self, packages: Iterable[str], now: bool) -> None: + """ + add packages + :param packages: packages list to add + :param now: build packages now + """ + kwargs = {"now": ""} if now else {} + self.spawn_process("add", *packages, **kwargs) + + def packages_remove(self, packages: Iterable[str]) -> None: + """ + remove packages + :param packages: packages list to remove + """ + self.spawn_process("remove", *packages) + + def spawn_process(self, command: str, *args: str, **kwargs: str) -> None: + """ + spawn external ahriman process with supplied arguments + :param command: subcommand to run + :param args: positional command arguments + :param kwargs: named command arguments + """ + # default arguments + arguments = ["--architecture", self.architecture] + if self.configuration.path is not None: + arguments.extend(["--configuration", str(self.configuration.path)]) + # positional command arguments + arguments.append(command) + arguments.extend(args) + # named command arguments + for argument, value in kwargs.items(): + arguments.append(f"--{argument}") + if value: + arguments.append(value) + + process_id = str(uuid.uuid4()) + self.logger.info("full command line arguments of %s are %s", process_id, arguments) + parsed = self.args_parser.parse_args(arguments) + + callback = parsed.handler.call + process = Process(target=self.process, + args=(callback, parsed, self.architecture, process_id, self.queue), + daemon=True) + process.start() + + with self.lock: + self.active[process_id] = process + + def run(self) -> None: + """ + thread run method + """ + for process_id, status in iter(self.queue.get, None): + self.logger.info("process %s has been terminated with status %s", process_id, status) + + with self.lock: + process = self.active.pop(process_id, None) + + if process is not None: + process.terminate() # make sure lol + process.join() diff --git a/src/ahriman/core/status/watcher.py b/src/ahriman/core/status/watcher.py index 7d8e3f2e..8d320853 100644 --- a/src/ahriman/core/status/watcher.py +++ b/src/ahriman/core/status/watcher.py @@ -49,7 +49,7 @@ class Watcher: self.logger = logging.getLogger("http") self.architecture = architecture - self.repository = Repository(architecture, configuration) + self.repository = Repository(architecture, configuration, no_report=True) self.known: Dict[str, Tuple[Package, BuildStatus]] = {} self.status = BuildStatus() diff --git a/src/ahriman/core/status/web_client.py b/src/ahriman/core/status/web_client.py index badd9c52..122d8579 100644 --- a/src/ahriman/core/status/web_client.py +++ b/src/ahriman/core/status/web_client.py @@ -65,7 +65,7 @@ class WebClient(Client): """ :return: full url for web service to login """ - return f"{self.address}/login" + return f"{self.address}/user-api/v1/login" @property def _status_url(self) -> str: diff --git a/src/ahriman/models/user.py b/src/ahriman/models/user.py index 73e1f3b9..ef0c58bb 100644 --- a/src/ahriman/models/user.py +++ b/src/ahriman/models/user.py @@ -51,7 +51,7 @@ class User: """ if username is None or password is None: return None - return cls(username, password, UserAccess.Status) + return cls(username, password, UserAccess.Read) @staticmethod def generate_password(length: int) -> str: diff --git a/src/ahriman/models/user_access.py b/src/ahriman/models/user_access.py index b0a9f1fc..4eb78272 100644 --- a/src/ahriman/models/user_access.py +++ b/src/ahriman/models/user_access.py @@ -25,9 +25,7 @@ class UserAccess(Enum): web user access enumeration :cvar Read: user can read status page :cvar Write: user can modify task and package list - :cvar Status: user can update statuses via API """ Read = "read" Write = "write" - Status = "status" diff --git a/src/ahriman/web/middlewares/auth_handler.py b/src/ahriman/web/middlewares/auth_handler.py index 26da6725..b8b51fd1 100644 --- a/src/ahriman/web/middlewares/auth_handler.py +++ b/src/ahriman/web/middlewares/auth_handler.py @@ -73,9 +73,7 @@ def auth_handler(validator: Auth) -> MiddlewareType: """ @middleware async def handle(request: Request, handler: HandlerType) -> StreamResponse: - if request.path.startswith("/status-api"): - permission = UserAccess.Status - elif request.method in ("GET", "HEAD", "OPTIONS"): + if request.method in ("GET", "HEAD", "OPTIONS"): permission = UserAccess.Read else: permission = UserAccess.Write diff --git a/src/ahriman/web/routes.py b/src/ahriman/web/routes.py index 0c6ba966..17c22d5c 100644 --- a/src/ahriman/web/routes.py +++ b/src/ahriman/web/routes.py @@ -19,13 +19,16 @@ # from aiohttp.web import Application -from ahriman.web.views.ahriman import AhrimanView from ahriman.web.views.index import IndexView -from ahriman.web.views.login import LoginView -from ahriman.web.views.logout import LogoutView -from ahriman.web.views.package import PackageView -from ahriman.web.views.packages import PackagesView -from ahriman.web.views.status import StatusView +from ahriman.web.views.service.add import AddView +from ahriman.web.views.service.remove import RemoveView +from ahriman.web.views.service.search import SearchView +from ahriman.web.views.status.ahriman import AhrimanView +from ahriman.web.views.status.package import PackageView +from ahriman.web.views.status.packages import PackagesView +from ahriman.web.views.status.status import StatusView +from ahriman.web.views.user.login import LoginView +from ahriman.web.views.user.logout import LogoutView def setup_routes(application: Application) -> None: @@ -37,8 +40,13 @@ def setup_routes(application: Application) -> None: GET / get build status page GET /index.html same as above - POST /login login to service - POST /logout logout from service + POST /service-api/v1/add add new packages to repository + + POST /service-api/v1/remove remove existing package from repository + + POST /service-api/v1/update update packages in repository, actually it is just alias for add + + GET /service-api/v1/search search for substring in AUR GET /status-api/v1/ahriman get current service status POST /status-api/v1/ahriman update service status @@ -52,13 +60,21 @@ def setup_routes(application: Application) -> None: GET /status-api/v1/status get web service status itself + POST /user-api/v1/login login to service + POST /user-api/v1/logout logout from service + :param application: web application instance """ application.router.add_get("/", IndexView, allow_head=True) application.router.add_get("/index.html", IndexView, allow_head=True) - application.router.add_post("/login", LoginView) - application.router.add_post("/logout", LogoutView) + application.router.add_post("/service-api/v1/add", AddView) + + application.router.add_post("/service-api/v1/remove", RemoveView) + + application.router.add_get("/service-api/v1/search", SearchView, allow_head=False) + + application.router.add_post("/service-api/v1/update", AddView) application.router.add_get("/status-api/v1/ahriman", AhrimanView, allow_head=True) application.router.add_post("/status-api/v1/ahriman", AhrimanView) @@ -71,3 +87,6 @@ def setup_routes(application: Application) -> None: application.router.add_post("/status-api/v1/packages/{package}", PackageView) application.router.add_get("/status-api/v1/status", StatusView, allow_head=True) + + application.router.add_post("/user-api/v1/login", LoginView) + application.router.add_post("/user-api/v1/logout", LogoutView) diff --git a/src/ahriman/web/views/base.py b/src/ahriman/web/views/base.py index 9b10ba68..78818e0a 100644 --- a/src/ahriman/web/views/base.py +++ b/src/ahriman/web/views/base.py @@ -18,9 +18,10 @@ # along with this program. If not, see . # from aiohttp.web import View -from typing import Any, Dict +from typing import Any, Dict, List, Optional from ahriman.core.auth.auth import Auth +from ahriman.core.spawn import Spawn from ahriman.core.status.watcher import Watcher @@ -37,6 +38,14 @@ class BaseView(View): watcher: Watcher = self.request.app["watcher"] return watcher + @property + def spawner(self) -> Spawn: + """ + :return: external process spawner instance + """ + spawner: Spawn = self.request.app["spawn"] + return spawner + @property def validator(self) -> Auth: """ @@ -45,13 +54,33 @@ class BaseView(View): validator: Auth = self.request.app["validator"] return validator - async def extract_data(self) -> Dict[str, Any]: + async def extract_data(self, list_keys: Optional[List[str]] = None) -> Dict[str, Any]: """ extract json data from either json or form data + :param list_keys: optional list of keys which must be forced to list from form data :return: raw json object or form data converted to json """ try: json: Dict[str, Any] = await self.request.json() return json except ValueError: - return dict(await self.request.post()) + return await self.data_as_json(list_keys or []) + + async def data_as_json(self, list_keys: List[str]) -> Dict[str, Any]: + """ + extract form data and convert it to json object + :param list_keys: list of keys which must be forced to list from form data + :return: form data converted to json. In case if a key is found multiple times it will be returned as list + """ + raw = await self.request.post() + json: Dict[str, Any] = {} + for key, value in raw.items(): + if key in json and isinstance(json[key], list): + json[key].append(value) + elif key in json: + json[key] = [json[key], value] + elif key in list_keys: + json[key] = [value] + else: + json[key] = value + return json diff --git a/src/ahriman/web/views/service/__init__.py b/src/ahriman/web/views/service/__init__.py new file mode 100644 index 00000000..fb32931e --- /dev/null +++ b/src/ahriman/web/views/service/__init__.py @@ -0,0 +1,19 @@ +# +# Copyright (c) 2021 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 . +# diff --git a/src/ahriman/web/views/service/add.py b/src/ahriman/web/views/service/add.py new file mode 100644 index 00000000..19a3ba2c --- /dev/null +++ b/src/ahriman/web/views/service/add.py @@ -0,0 +1,52 @@ +# +# Copyright (c) 2021 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 HTTPFound, Response, json_response + +from ahriman.web.views.base import BaseView + + +class AddView(BaseView): + """ + add package web view + """ + + async def post(self) -> Response: + """ + add new package + + JSON body must be supplied, the following model is used: + { + "packages": "ahriman", # either list of packages or package name as in AUR + "build_now": true # optional flag which runs build + } + + :return: redirect to main page on success + """ + data = await self.extract_data(["packages"]) + + try: + now = data.get("build_now", True) + packages = data["packages"] + except Exception as e: + return json_response(text=str(e), status=400) + + self.spawner.packages_add(packages, now) + + return HTTPFound("/") diff --git a/src/ahriman/web/views/service/remove.py b/src/ahriman/web/views/service/remove.py new file mode 100644 index 00000000..403f15b2 --- /dev/null +++ b/src/ahriman/web/views/service/remove.py @@ -0,0 +1,50 @@ +# +# Copyright (c) 2021 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 HTTPFound, Response, json_response + +from ahriman.web.views.base import BaseView + + +class RemoveView(BaseView): + """ + remove package web view + """ + + async def post(self) -> Response: + """ + remove existing packages + + JSON body must be supplied, the following model is used: + { + "packages": "ahriman", # either list of packages or package name + } + + :return: redirect to main page on success + """ + data = await self.extract_data(["packages"]) + + try: + packages = data["packages"] + except Exception as e: + return json_response(text=str(e), status=400) + + self.spawner.packages_remove(packages) + + return HTTPFound("/") diff --git a/src/ahriman/web/views/service/search.py b/src/ahriman/web/views/service/search.py new file mode 100644 index 00000000..b2e781a5 --- /dev/null +++ b/src/ahriman/web/views/service/search.py @@ -0,0 +1,48 @@ +# +# Copyright (c) 2021 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 aur # type: ignore + +from aiohttp.web import Response, json_response +from typing import Iterator + +from ahriman.web.views.base import BaseView + + +class SearchView(BaseView): + """ + AUR search web view + """ + + async def get(self) -> Response: + """ + search packages in AUR + + search string (non empty) must be supplied as `for` parameter + + :return: 200 with found package bases sorted by name + """ + search: Iterator[str] = filter(lambda s: len(s) > 3, self.request.query.getall("for", default=[])) + search_string = " ".join(search) + + if not search_string: + return json_response(text="Search string must not be empty", status=400) + packages = aur.search(search_string) + + return json_response(sorted(package.package_base for package in packages)) diff --git a/src/ahriman/web/views/status/__init__.py b/src/ahriman/web/views/status/__init__.py new file mode 100644 index 00000000..fb32931e --- /dev/null +++ b/src/ahriman/web/views/status/__init__.py @@ -0,0 +1,19 @@ +# +# Copyright (c) 2021 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 . +# diff --git a/src/ahriman/web/views/ahriman.py b/src/ahriman/web/views/status/ahriman.py similarity index 92% rename from src/ahriman/web/views/ahriman.py rename to src/ahriman/web/views/status/ahriman.py index 42f85bc8..80a09b8e 100644 --- a/src/ahriman/web/views/ahriman.py +++ b/src/ahriman/web/views/status/ahriman.py @@ -17,7 +17,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . # -from aiohttp.web import HTTPBadRequest, HTTPNoContent, Response, json_response +from aiohttp.web import HTTPNoContent, Response, json_response from ahriman.models.build_status import BuildStatusEnum from ahriman.web.views.base import BaseView @@ -51,7 +51,7 @@ class AhrimanView(BaseView): try: status = BuildStatusEnum(data["status"]) except Exception as e: - raise HTTPBadRequest(text=str(e)) + return json_response(text=str(e), status=400) self.service.update_self(status) diff --git a/src/ahriman/web/views/package.py b/src/ahriman/web/views/status/package.py similarity index 91% rename from src/ahriman/web/views/package.py rename to src/ahriman/web/views/status/package.py index 3789a896..234bb5f8 100644 --- a/src/ahriman/web/views/package.py +++ b/src/ahriman/web/views/status/package.py @@ -17,7 +17,7 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . # -from aiohttp.web import HTTPBadRequest, HTTPNoContent, HTTPNotFound, Response, json_response +from aiohttp.web import HTTPNoContent, HTTPNotFound, Response, json_response from ahriman.core.exceptions import UnknownPackage from ahriman.models.build_status import BuildStatusEnum @@ -80,11 +80,11 @@ class PackageView(BaseView): package = Package.from_json(data["package"]) if "package" in data else None status = BuildStatusEnum(data["status"]) except Exception as e: - raise HTTPBadRequest(text=str(e)) + return json_response(text=str(e), status=400) try: self.service.update(base, status, package) except UnknownPackage: - raise HTTPBadRequest(text=f"Package {base} is unknown, but no package body set") + return json_response(text=f"Package {base} is unknown, but no package body set", status=400) return HTTPNoContent() diff --git a/src/ahriman/web/views/packages.py b/src/ahriman/web/views/status/packages.py similarity index 100% rename from src/ahriman/web/views/packages.py rename to src/ahriman/web/views/status/packages.py diff --git a/src/ahriman/web/views/status.py b/src/ahriman/web/views/status/status.py similarity index 100% rename from src/ahriman/web/views/status.py rename to src/ahriman/web/views/status/status.py diff --git a/src/ahriman/web/views/user/__init__.py b/src/ahriman/web/views/user/__init__.py new file mode 100644 index 00000000..fb32931e --- /dev/null +++ b/src/ahriman/web/views/user/__init__.py @@ -0,0 +1,19 @@ +# +# Copyright (c) 2021 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 . +# diff --git a/src/ahriman/web/views/login.py b/src/ahriman/web/views/user/login.py similarity index 100% rename from src/ahriman/web/views/login.py rename to src/ahriman/web/views/user/login.py diff --git a/src/ahriman/web/views/logout.py b/src/ahriman/web/views/user/logout.py similarity index 100% rename from src/ahriman/web/views/logout.py rename to src/ahriman/web/views/user/logout.py diff --git a/src/ahriman/web/web.py b/src/ahriman/web/web.py index 1cf98d3e..36a2b989 100644 --- a/src/ahriman/web/web.py +++ b/src/ahriman/web/web.py @@ -26,6 +26,7 @@ from aiohttp import web from ahriman.core.auth.auth import Auth from ahriman.core.configuration import Configuration from ahriman.core.exceptions import InitializeException +from ahriman.core.spawn import Spawn from ahriman.core.status.watcher import Watcher from ahriman.web.middlewares.exception_handler import exception_handler from ahriman.web.routes import setup_routes @@ -67,11 +68,12 @@ def run_server(application: web.Application) -> None: access_log=logging.getLogger("http")) -def setup_service(architecture: str, configuration: Configuration) -> web.Application: +def setup_service(architecture: str, configuration: Configuration, spawner: Spawn) -> web.Application: """ create web application :param architecture: repository architecture :param configuration: configuration instance + :param spawner: spawner thread :return: web application instance """ application = web.Application(logger=logging.getLogger("http")) @@ -93,6 +95,9 @@ def setup_service(architecture: str, configuration: Configuration) -> web.Applic application.logger.info("setup watcher") application["watcher"] = Watcher(architecture, configuration) + application.logger.info("setup process spawner") + application["spawn"] = spawner + application.logger.info("setup authorization") validator = application["validator"] = Auth.load(configuration) if validator.enabled: diff --git a/tests/ahriman/application/conftest.py b/tests/ahriman/application/conftest.py index 2dc50ec4..872031d6 100644 --- a/tests/ahriman/application/conftest.py +++ b/tests/ahriman/application/conftest.py @@ -1,5 +1,4 @@ import argparse -import aur import pytest from pytest_mock import MockerFixture @@ -8,7 +7,6 @@ from ahriman.application.ahriman import _parser from ahriman.application.application import Application from ahriman.application.lock import Lock from ahriman.core.configuration import Configuration -from ahriman.models.package import Package @pytest.fixture @@ -20,7 +18,7 @@ def application(configuration: Configuration, mocker: MockerFixture) -> Applicat :return: application test instance """ mocker.patch("pathlib.Path.mkdir") - return Application("x86_64", configuration) + return Application("x86_64", configuration, no_report=True) @pytest.fixture @@ -32,31 +30,6 @@ def args() -> argparse.Namespace: return argparse.Namespace(lock=None, force=False, unsafe=False, no_report=True) -@pytest.fixture -def aur_package_ahriman(package_ahriman: Package) -> aur.Package: - """ - fixture for AUR package - :param package_ahriman: package fixture - :return: AUR package test instance - """ - return aur.Package( - num_votes=None, - description=package_ahriman.packages[package_ahriman.base].description, - url_path=package_ahriman.web_url, - last_modified=None, - name=package_ahriman.base, - out_of_date=None, - id=None, - first_submitted=None, - maintainer=None, - version=package_ahriman.version, - license=package_ahriman.packages[package_ahriman.base].licenses, - url=None, - package_base=package_ahriman.base, - package_base_id=None, - category_id=None) - - @pytest.fixture def lock(args: argparse.Namespace, configuration: Configuration) -> Lock: """ diff --git a/tests/ahriman/application/handlers/test_handler.py b/tests/ahriman/application/handlers/test_handler.py index cd7c9efa..a8a770b0 100644 --- a/tests/ahriman/application/handlers/test_handler.py +++ b/tests/ahriman/application/handlers/test_handler.py @@ -6,7 +6,7 @@ from pytest_mock import MockerFixture from ahriman.application.handlers import Handler from ahriman.core.configuration import Configuration -from ahriman.core.exceptions import MissingArchitecture +from ahriman.core.exceptions import MissingArchitecture, MultipleArchitecture def test_call(args: argparse.Namespace, mocker: MockerFixture) -> None: @@ -20,7 +20,7 @@ def test_call(args: argparse.Namespace, mocker: MockerFixture) -> None: enter_mock = mocker.patch("ahriman.application.lock.Lock.__enter__") exit_mock = mocker.patch("ahriman.application.lock.Lock.__exit__") - assert Handler._call(args, "x86_64") + assert Handler.call(args, "x86_64") enter_mock.assert_called_once() exit_mock.assert_called_once() @@ -30,7 +30,7 @@ def test_call_exception(args: argparse.Namespace, mocker: MockerFixture) -> None must process exception """ mocker.patch("ahriman.application.lock.Lock.__enter__", side_effect=Exception()) - assert not Handler._call(args, "x86_64") + assert not Handler.call(args, "x86_64") def test_execute(args: argparse.Namespace, mocker: MockerFixture) -> None: @@ -44,6 +44,29 @@ def test_execute(args: argparse.Namespace, mocker: MockerFixture) -> None: starmap_mock.assert_called_once() +def test_execute_multiple_not_supported(args: argparse.Namespace, mocker: MockerFixture) -> None: + """ + must raise an exception if multiple architectures are not supported by the handler + """ + args.architecture = ["i686", "x86_64"] + args.command = "web" + mocker.patch.object(Handler, "ALLOW_MULTI_ARCHITECTURE_RUN", False) + + with pytest.raises(MultipleArchitecture): + Handler.execute(args) + + +def test_execute_single(args: argparse.Namespace, mocker: MockerFixture) -> None: + """ + must run execution in current process if only one architecture supplied + """ + args.architecture = ["x86_64"] + starmap_mock = mocker.patch("multiprocessing.pool.Pool.starmap") + + Handler.execute(args) + starmap_mock.assert_not_called() + + def test_extract_architectures(args: argparse.Namespace, mocker: MockerFixture) -> None: """ must generate list of available architectures @@ -94,4 +117,4 @@ def test_run(args: argparse.Namespace, configuration: Configuration) -> None: must raise NotImplemented for missing method """ with pytest.raises(NotImplementedError): - Handler.run(args, "x86_64", configuration) + Handler.run(args, "x86_64", configuration, True) diff --git a/tests/ahriman/application/handlers/test_handler_add.py b/tests/ahriman/application/handlers/test_handler_add.py index 25ab8d64..baad1abf 100644 --- a/tests/ahriman/application/handlers/test_handler_add.py +++ b/tests/ahriman/application/handlers/test_handler_add.py @@ -26,7 +26,7 @@ def test_run(args: argparse.Namespace, configuration: Configuration, mocker: Moc mocker.patch("pathlib.Path.mkdir") application_mock = mocker.patch("ahriman.application.application.Application.add") - Add.run(args, "x86_64", configuration) + Add.run(args, "x86_64", configuration, True) application_mock.assert_called_once() @@ -41,6 +41,6 @@ def test_run_with_updates(args: argparse.Namespace, configuration: Configuration application_mock = mocker.patch("ahriman.application.application.Application.update") updates_mock = mocker.patch("ahriman.application.application.Application.get_updates") - Add.run(args, "x86_64", configuration) + Add.run(args, "x86_64", configuration, True) application_mock.assert_called_once() updates_mock.assert_called_once() diff --git a/tests/ahriman/application/handlers/test_handler_clean.py b/tests/ahriman/application/handlers/test_handler_clean.py index ca1edf44..c0416934 100644 --- a/tests/ahriman/application/handlers/test_handler_clean.py +++ b/tests/ahriman/application/handlers/test_handler_clean.py @@ -28,5 +28,5 @@ def test_run(args: argparse.Namespace, configuration: Configuration, mocker: Moc mocker.patch("pathlib.Path.mkdir") application_mock = mocker.patch("ahriman.application.application.Application.clean") - Clean.run(args, "x86_64", configuration) + Clean.run(args, "x86_64", configuration, True) application_mock.assert_called_once() diff --git a/tests/ahriman/application/handlers/test_handler_create_user.py b/tests/ahriman/application/handlers/test_handler_create_user.py index f251857d..cda99fdb 100644 --- a/tests/ahriman/application/handlers/test_handler_create_user.py +++ b/tests/ahriman/application/handlers/test_handler_create_user.py @@ -19,7 +19,7 @@ def _default_args(args: argparse.Namespace) -> argparse.Namespace: """ args.username = "user" args.password = "pa55w0rd" - args.role = UserAccess.Status + args.role = UserAccess.Read args.as_service = False return args @@ -34,7 +34,7 @@ def test_run(args: argparse.Namespace, configuration: Configuration, mocker: Moc create_user = mocker.patch("ahriman.application.handlers.CreateUser.create_user") get_salt_mock = mocker.patch("ahriman.application.handlers.CreateUser.get_salt") - CreateUser.run(args, "x86_64", configuration) + CreateUser.run(args, "x86_64", configuration, True) get_auth_configuration_mock.assert_called_once() create_configuration_mock.assert_called_once() create_user.assert_called_once() diff --git a/tests/ahriman/application/handlers/test_handler_dump.py b/tests/ahriman/application/handlers/test_handler_dump.py index 1864ae8f..f8962c15 100644 --- a/tests/ahriman/application/handlers/test_handler_dump.py +++ b/tests/ahriman/application/handlers/test_handler_dump.py @@ -15,6 +15,6 @@ def test_run(args: argparse.Namespace, configuration: Configuration, mocker: Moc application_mock = mocker.patch("ahriman.core.configuration.Configuration.dump", return_value=configuration.dump()) - Dump.run(args, "x86_64", configuration) + Dump.run(args, "x86_64", configuration, True) application_mock.assert_called_once() print_mock.assert_called() diff --git a/tests/ahriman/application/handlers/test_handler_init.py b/tests/ahriman/application/handlers/test_handler_init.py index 4d70153c..82156a86 100644 --- a/tests/ahriman/application/handlers/test_handler_init.py +++ b/tests/ahriman/application/handlers/test_handler_init.py @@ -13,6 +13,6 @@ def test_run(args: argparse.Namespace, configuration: Configuration, mocker: Moc create_tree_mock = mocker.patch("ahriman.models.repository_paths.RepositoryPaths.create_tree") init_mock = mocker.patch("ahriman.core.alpm.repo.Repo.init") - Init.run(args, "x86_64", configuration) + Init.run(args, "x86_64", configuration, True) create_tree_mock.assert_called_once() init_mock.assert_called_once() diff --git a/tests/ahriman/application/handlers/test_handler_key_import.py b/tests/ahriman/application/handlers/test_handler_key_import.py index 87279539..aede5841 100644 --- a/tests/ahriman/application/handlers/test_handler_key_import.py +++ b/tests/ahriman/application/handlers/test_handler_key_import.py @@ -25,5 +25,5 @@ def test_run(args: argparse.Namespace, configuration: Configuration, mocker: Moc mocker.patch("pathlib.Path.mkdir") application_mock = mocker.patch("ahriman.core.sign.gpg.GPG.import_key") - KeyImport.run(args, "x86_64", configuration) + KeyImport.run(args, "x86_64", configuration, True) application_mock.assert_called_once() diff --git a/tests/ahriman/application/handlers/test_handler_rebuild.py b/tests/ahriman/application/handlers/test_handler_rebuild.py index 32dfcc55..f77b70b1 100644 --- a/tests/ahriman/application/handlers/test_handler_rebuild.py +++ b/tests/ahriman/application/handlers/test_handler_rebuild.py @@ -26,7 +26,7 @@ def test_run(args: argparse.Namespace, configuration: Configuration, mocker: Moc application_packages_mock = mocker.patch("ahriman.core.repository.repository.Repository.packages") application_mock = mocker.patch("ahriman.application.application.Application.update") - Rebuild.run(args, "x86_64", configuration) + Rebuild.run(args, "x86_64", configuration, True) application_packages_mock.assert_called_once() application_mock.assert_called_once() @@ -44,7 +44,7 @@ def test_run_filter(args: argparse.Namespace, configuration: Configuration, return_value=[package_ahriman, package_python_schedule]) application_mock = mocker.patch("ahriman.application.application.Application.update") - Rebuild.run(args, "x86_64", configuration) + Rebuild.run(args, "x86_64", configuration, True) application_mock.assert_called_with([package_ahriman]) @@ -60,5 +60,5 @@ def test_run_without_filter(args: argparse.Namespace, configuration: Configurati return_value=[package_ahriman, package_python_schedule]) application_mock = mocker.patch("ahriman.application.application.Application.update") - Rebuild.run(args, "x86_64", configuration) + Rebuild.run(args, "x86_64", configuration, True) application_mock.assert_called_with([package_ahriman, package_python_schedule]) diff --git a/tests/ahriman/application/handlers/test_handler_remove.py b/tests/ahriman/application/handlers/test_handler_remove.py index b64f9ccf..556cafc3 100644 --- a/tests/ahriman/application/handlers/test_handler_remove.py +++ b/tests/ahriman/application/handlers/test_handler_remove.py @@ -24,5 +24,5 @@ def test_run(args: argparse.Namespace, configuration: Configuration, mocker: Moc mocker.patch("pathlib.Path.mkdir") application_mock = mocker.patch("ahriman.application.application.Application.remove") - Remove.run(args, "x86_64", configuration) + Remove.run(args, "x86_64", configuration, True) application_mock.assert_called_once() diff --git a/tests/ahriman/application/handlers/test_handler_remove_unknown.py b/tests/ahriman/application/handlers/test_handler_remove_unknown.py index 5d83ce47..861b6377 100644 --- a/tests/ahriman/application/handlers/test_handler_remove_unknown.py +++ b/tests/ahriman/application/handlers/test_handler_remove_unknown.py @@ -26,7 +26,7 @@ def test_run(args: argparse.Namespace, configuration: Configuration, mocker: Moc application_mock = mocker.patch("ahriman.application.application.Application.unknown") remove_mock = mocker.patch("ahriman.application.application.Application.remove") - RemoveUnknown.run(args, "x86_64", configuration) + RemoveUnknown.run(args, "x86_64", configuration, True) application_mock.assert_called_once() remove_mock.assert_called_once() @@ -44,7 +44,7 @@ def test_run_dry_run(args: argparse.Namespace, configuration: Configuration, pac remove_mock = mocker.patch("ahriman.application.application.Application.remove") log_fn_mock = mocker.patch("ahriman.application.handlers.remove_unknown.RemoveUnknown.log_fn") - RemoveUnknown.run(args, "x86_64", configuration) + RemoveUnknown.run(args, "x86_64", configuration, True) application_mock.assert_called_once() remove_mock.assert_not_called() log_fn_mock.assert_called_with(package_ahriman) diff --git a/tests/ahriman/application/handlers/test_handler_report.py b/tests/ahriman/application/handlers/test_handler_report.py index 5f366038..c4b38340 100644 --- a/tests/ahriman/application/handlers/test_handler_report.py +++ b/tests/ahriman/application/handlers/test_handler_report.py @@ -24,5 +24,5 @@ def test_run(args: argparse.Namespace, configuration: Configuration, mocker: Moc mocker.patch("pathlib.Path.mkdir") application_mock = mocker.patch("ahriman.application.application.Application.report") - Report.run(args, "x86_64", configuration) + Report.run(args, "x86_64", configuration, True) application_mock.assert_called_once() diff --git a/tests/ahriman/application/handlers/test_handler_search.py b/tests/ahriman/application/handlers/test_handler_search.py index 68c432f7..891e6119 100644 --- a/tests/ahriman/application/handlers/test_handler_search.py +++ b/tests/ahriman/application/handlers/test_handler_search.py @@ -26,7 +26,7 @@ def test_run(args: argparse.Namespace, configuration: Configuration, aur_package mocker.patch("aur.search", return_value=[aur_package_ahriman]) log_mock = mocker.patch("ahriman.application.handlers.search.Search.log_fn") - Search.run(args, "x86_64", configuration) + Search.run(args, "x86_64", configuration, True) log_mock.assert_called_once() @@ -38,7 +38,7 @@ def test_run_multiple_search(args: argparse.Namespace, configuration: Configurat args.search = ["ahriman", "is", "cool"] search_mock = mocker.patch("aur.search") - Search.run(args, "x86_64", configuration) + Search.run(args, "x86_64", configuration, True) search_mock.assert_called_with(" ".join(args.search)) @@ -51,5 +51,5 @@ def test_log_fn(args: argparse.Namespace, configuration: Configuration, aur_pack mocker.patch("aur.search", return_value=[aur_package_ahriman]) print_mock = mocker.patch("builtins.print") - Search.run(args, "x86_64", configuration) + Search.run(args, "x86_64", configuration, True) print_mock.assert_called() # we don't really care about call details tbh diff --git a/tests/ahriman/application/handlers/test_handler_setup.py b/tests/ahriman/application/handlers/test_handler_setup.py index 8e5ca894..e51fa1fe 100644 --- a/tests/ahriman/application/handlers/test_handler_setup.py +++ b/tests/ahriman/application/handlers/test_handler_setup.py @@ -39,7 +39,7 @@ def test_run(args: argparse.Namespace, configuration: Configuration, mocker: Moc sudo_configuration_mock = mocker.patch("ahriman.application.handlers.setup.Setup.create_sudo_configuration") executable_mock = mocker.patch("ahriman.application.handlers.setup.Setup.create_executable") - Setup.run(args, "x86_64", configuration) + Setup.run(args, "x86_64", configuration, True) ahriman_configuration_mock.assert_called_once() devtools_configuration_mock.assert_called_once() makepkg_configuration_mock.assert_called_once() diff --git a/tests/ahriman/application/handlers/test_handler_sign.py b/tests/ahriman/application/handlers/test_handler_sign.py index bd8e73b6..f4500d96 100644 --- a/tests/ahriman/application/handlers/test_handler_sign.py +++ b/tests/ahriman/application/handlers/test_handler_sign.py @@ -24,5 +24,5 @@ def test_run(args: argparse.Namespace, configuration: Configuration, mocker: Moc mocker.patch("pathlib.Path.mkdir") application_mock = mocker.patch("ahriman.application.application.Application.sign") - Sign.run(args, "x86_64", configuration) + Sign.run(args, "x86_64", configuration, True) application_mock.assert_called_once() diff --git a/tests/ahriman/application/handlers/test_handler_status.py b/tests/ahriman/application/handlers/test_handler_status.py index 143028ff..045705d6 100644 --- a/tests/ahriman/application/handlers/test_handler_status.py +++ b/tests/ahriman/application/handlers/test_handler_status.py @@ -30,7 +30,7 @@ def test_run(args: argparse.Namespace, configuration: Configuration, package_ahr packages_mock = mocker.patch("ahriman.core.status.client.Client.get", return_value=[(package_ahriman, BuildStatus())]) - Status.run(args, "x86_64", configuration) + Status.run(args, "x86_64", configuration, True) application_mock.assert_called_once() packages_mock.assert_called_once() @@ -46,5 +46,17 @@ def test_run_with_package_filter(args: argparse.Namespace, configuration: Config packages_mock = mocker.patch("ahriman.core.status.client.Client.get", return_value=[(package_ahriman, BuildStatus())]) - Status.run(args, "x86_64", configuration) + Status.run(args, "x86_64", configuration, True) packages_mock.assert_called_once() + + +def test_imply_with_report(args: argparse.Namespace, configuration: Configuration, mocker: MockerFixture) -> None: + """ + must create application object with native reporting + """ + args = _default_args(args) + mocker.patch("pathlib.Path.mkdir") + load_mock = mocker.patch("ahriman.core.status.client.Client.load") + + Status.run(args, "x86_64", configuration, True) + load_mock.assert_called_once() diff --git a/tests/ahriman/application/handlers/test_handler_status_update.py b/tests/ahriman/application/handlers/test_handler_status_update.py index 4b4cc399..dc793577 100644 --- a/tests/ahriman/application/handlers/test_handler_status_update.py +++ b/tests/ahriman/application/handlers/test_handler_status_update.py @@ -28,7 +28,7 @@ def test_run(args: argparse.Namespace, configuration: Configuration, mocker: Moc mocker.patch("pathlib.Path.mkdir") update_self_mock = mocker.patch("ahriman.core.status.client.Client.update_self") - StatusUpdate.run(args, "x86_64", configuration) + StatusUpdate.run(args, "x86_64", configuration, True) update_self_mock.assert_called_once() @@ -42,7 +42,7 @@ def test_run_packages(args: argparse.Namespace, configuration: Configuration, pa mocker.patch("pathlib.Path.mkdir") update_mock = mocker.patch("ahriman.core.status.client.Client.update") - StatusUpdate.run(args, "x86_64", configuration) + StatusUpdate.run(args, "x86_64", configuration, True) update_mock.assert_called_once() @@ -57,5 +57,17 @@ def test_run_remove(args: argparse.Namespace, configuration: Configuration, pack mocker.patch("pathlib.Path.mkdir") update_mock = mocker.patch("ahriman.core.status.client.Client.remove") - StatusUpdate.run(args, "x86_64", configuration) + StatusUpdate.run(args, "x86_64", configuration, True) update_mock.assert_called_once() + + +def test_imply_with_report(args: argparse.Namespace, configuration: Configuration, mocker: MockerFixture) -> None: + """ + must create application object with native reporting + """ + args = _default_args(args) + mocker.patch("pathlib.Path.mkdir") + load_mock = mocker.patch("ahriman.core.status.client.Client.load") + + StatusUpdate.run(args, "x86_64", configuration, True) + load_mock.assert_called_once() diff --git a/tests/ahriman/application/handlers/test_handler_sync.py b/tests/ahriman/application/handlers/test_handler_sync.py index 660d71fb..91190da7 100644 --- a/tests/ahriman/application/handlers/test_handler_sync.py +++ b/tests/ahriman/application/handlers/test_handler_sync.py @@ -24,5 +24,5 @@ def test_run(args: argparse.Namespace, configuration: Configuration, mocker: Moc mocker.patch("pathlib.Path.mkdir") application_mock = mocker.patch("ahriman.application.application.Application.sync") - Sync.run(args, "x86_64", configuration) + Sync.run(args, "x86_64", configuration, True) application_mock.assert_called_once() diff --git a/tests/ahriman/application/handlers/test_handler_update.py b/tests/ahriman/application/handlers/test_handler_update.py index 6861f034..acc93766 100644 --- a/tests/ahriman/application/handlers/test_handler_update.py +++ b/tests/ahriman/application/handlers/test_handler_update.py @@ -30,7 +30,7 @@ def test_run(args: argparse.Namespace, configuration: Configuration, mocker: Moc application_mock = mocker.patch("ahriman.application.application.Application.update") updates_mock = mocker.patch("ahriman.application.application.Application.get_updates") - Update.run(args, "x86_64", configuration) + Update.run(args, "x86_64", configuration, True) application_mock.assert_called_once() updates_mock.assert_called_once() @@ -44,7 +44,7 @@ def test_run_dry_run(args: argparse.Namespace, configuration: Configuration, moc mocker.patch("pathlib.Path.mkdir") updates_mock = mocker.patch("ahriman.application.application.Application.get_updates") - Update.run(args, "x86_64", configuration) + Update.run(args, "x86_64", configuration, True) updates_mock.assert_called_once() diff --git a/tests/ahriman/application/handlers/test_handler_web.py b/tests/ahriman/application/handlers/test_handler_web.py index 62f45f97..0f5e91e1 100644 --- a/tests/ahriman/application/handlers/test_handler_web.py +++ b/tests/ahriman/application/handlers/test_handler_web.py @@ -6,14 +6,33 @@ from ahriman.application.handlers import Web from ahriman.core.configuration import Configuration +def _default_args(args: argparse.Namespace) -> argparse.Namespace: + """ + default arguments for these test cases + :param args: command line arguments fixture + :return: generated arguments for these test cases + """ + args.parser = lambda: True + return args + + def test_run(args: argparse.Namespace, configuration: Configuration, mocker: MockerFixture) -> None: """ must run command """ + args = _default_args(args) mocker.patch("pathlib.Path.mkdir") + mocker.patch("ahriman.core.spawn.Spawn.start") setup_mock = mocker.patch("ahriman.web.web.setup_service") run_mock = mocker.patch("ahriman.web.web.run_server") - Web.run(args, "x86_64", configuration) + Web.run(args, "x86_64", configuration, True) setup_mock.assert_called_once() run_mock.assert_called_once() + + +def test_disallow_multi_architecture_run() -> None: + """ + must not allow multi architecture run + """ + assert not Web.ALLOW_MULTI_ARCHITECTURE_RUN diff --git a/tests/ahriman/application/test_ahriman.py b/tests/ahriman/application/test_ahriman.py index 91838703..a4df4bc9 100644 --- a/tests/ahriman/application/test_ahriman.py +++ b/tests/ahriman/application/test_ahriman.py @@ -260,11 +260,12 @@ def test_subparsers_update(parser: argparse.ArgumentParser) -> None: def test_subparsers_web(parser: argparse.ArgumentParser) -> None: """ - web command must imply lock and no_report + web command must imply lock, no_report and parser """ args = parser.parse_args(["-a", "x86_64", "web"]) assert args.lock is None assert args.no_report + assert args.parser is not None and args.parser() def test_run(args: argparse.Namespace, mocker: MockerFixture) -> None: diff --git a/tests/ahriman/conftest.py b/tests/ahriman/conftest.py index 99a600fe..91a0304c 100644 --- a/tests/ahriman/conftest.py +++ b/tests/ahriman/conftest.py @@ -1,11 +1,14 @@ +import aur import pytest from pathlib import Path from pytest_mock import MockerFixture from typing import Any, Type, TypeVar +from unittest.mock import MagicMock from ahriman.core.auth.auth import Auth from ahriman.core.configuration import Configuration +from ahriman.core.spawn import Spawn from ahriman.core.status.watcher import Watcher from ahriman.models.package import Package from ahriman.models.package_description import PackageDescription @@ -13,6 +16,7 @@ from ahriman.models.repository_paths import RepositoryPaths from ahriman.models.user import User from ahriman.models.user_access import UserAccess + T = TypeVar("T") @@ -43,10 +47,36 @@ def anyvar(cls: Type[T], strict: bool = False) -> T: # generic fixtures +@pytest.fixture +def aur_package_ahriman(package_ahriman: Package) -> aur.Package: + """ + fixture for AUR package + :param package_ahriman: package fixture + :return: AUR package test instance + """ + return aur.Package( + num_votes=None, + description=package_ahriman.packages[package_ahriman.base].description, + url_path=package_ahriman.web_url, + last_modified=None, + name=package_ahriman.base, + out_of_date=None, + id=None, + first_submitted=None, + maintainer=None, + version=package_ahriman.version, + license=package_ahriman.packages[package_ahriman.base].licenses, + url=None, + package_base=package_ahriman.base, + package_base_id=None, + category_id=None) + + @pytest.fixture def auth(configuration: Configuration) -> Auth: """ auth provider fixture + :param configuration: configuration fixture :return: auth service instance """ return Auth(configuration) @@ -160,6 +190,7 @@ def package_description_python2_schedule() -> PackageDescription: def repository_paths(configuration: Configuration) -> RepositoryPaths: """ repository paths fixture + :param configuration: configuration fixture :return: repository paths test instance """ return RepositoryPaths( @@ -167,13 +198,23 @@ def repository_paths(configuration: Configuration) -> RepositoryPaths: root=configuration.getpath("repository", "root")) +@pytest.fixture +def spawner(configuration: Configuration) -> Spawn: + """ + spawner fixture + :param configuration: configuration fixture + :return: spawner fixture + """ + return Spawn(MagicMock(), "x86_64", configuration) + + @pytest.fixture def user() -> User: """ fixture for user descriptor :return: user descriptor instance """ - return User("user", "pa55w0rd", UserAccess.Status) + return User("user", "pa55w0rd", UserAccess.Read) @pytest.fixture diff --git a/tests/ahriman/core/auth/test_auth.py b/tests/ahriman/core/auth/test_auth.py index 20d99218..5dda1f2a 100644 --- a/tests/ahriman/core/auth/test_auth.py +++ b/tests/ahriman/core/auth/test_auth.py @@ -46,8 +46,8 @@ def test_is_safe_request(auth: Auth) -> None: must validate safe request """ # login and logout are always safe - assert auth.is_safe_request("/login", UserAccess.Write) - assert auth.is_safe_request("/logout", UserAccess.Write) + assert auth.is_safe_request("/user-api/v1/login", UserAccess.Write) + assert auth.is_safe_request("/user-api/v1/logout", UserAccess.Write) auth.allowed_paths.add("/safe") auth.allowed_paths_groups.add("/unsafe/safe") diff --git a/tests/ahriman/core/repository/conftest.py b/tests/ahriman/core/repository/conftest.py index 2b8986d6..1e1057e4 100644 --- a/tests/ahriman/core/repository/conftest.py +++ b/tests/ahriman/core/repository/conftest.py @@ -19,7 +19,7 @@ def cleaner(configuration: Configuration, mocker: MockerFixture) -> Cleaner: :return: cleaner test instance """ mocker.patch("pathlib.Path.mkdir") - return Cleaner("x86_64", configuration) + return Cleaner("x86_64", configuration, no_report=True) @pytest.fixture @@ -36,7 +36,7 @@ def executor(configuration: Configuration, mocker: MockerFixture) -> Executor: mocker.patch("ahriman.core.repository.cleaner.Cleaner.clear_chroot") mocker.patch("ahriman.core.repository.cleaner.Cleaner.clear_manual") mocker.patch("ahriman.core.repository.cleaner.Cleaner.clear_packages") - return Executor("x86_64", configuration) + return Executor("x86_64", configuration, no_report=True) @pytest.fixture @@ -48,7 +48,7 @@ def repository(configuration: Configuration, mocker: MockerFixture) -> Repositor :return: repository test instance """ mocker.patch("pathlib.Path.mkdir") - return Repository("x86_64", configuration) + return Repository("x86_64", configuration, no_report=True) @pytest.fixture @@ -58,7 +58,7 @@ def properties(configuration: Configuration) -> Properties: :param configuration: configuration fixture :return: properties test instance """ - return Properties("x86_64", configuration) + return Properties("x86_64", configuration, no_report=True) @pytest.fixture @@ -75,4 +75,4 @@ def update_handler(configuration: Configuration, mocker: MockerFixture) -> Updat mocker.patch("ahriman.core.repository.cleaner.Cleaner.clear_chroot") mocker.patch("ahriman.core.repository.cleaner.Cleaner.clear_manual") mocker.patch("ahriman.core.repository.cleaner.Cleaner.clear_packages") - return UpdateHandler("x86_64", configuration) + return UpdateHandler("x86_64", configuration, no_report=True) diff --git a/tests/ahriman/core/repository/test_properties.py b/tests/ahriman/core/repository/test_properties.py index 760176c1..cda9ca4c 100644 --- a/tests/ahriman/core/repository/test_properties.py +++ b/tests/ahriman/core/repository/test_properties.py @@ -2,6 +2,7 @@ from pytest_mock import MockerFixture from ahriman.core.configuration import Configuration from ahriman.core.repository.properties import Properties +from ahriman.core.status.web_client import WebClient def test_create_tree_on_load(configuration: Configuration, mocker: MockerFixture) -> None: @@ -9,6 +10,29 @@ def test_create_tree_on_load(configuration: Configuration, mocker: MockerFixture must create tree on load """ create_tree_mock = mocker.patch("ahriman.models.repository_paths.RepositoryPaths.create_tree") - Properties("x86_64", configuration) + Properties("x86_64", configuration, True) create_tree_mock.assert_called_once() + + +def test_create_dummy_report_client(configuration: Configuration, mocker: MockerFixture) -> None: + """ + must create dummy report client if report is disabled + """ + mocker.patch("ahriman.models.repository_paths.RepositoryPaths.create_tree") + load_mock = mocker.patch("ahriman.core.status.client.Client.load") + properties = Properties("x86_64", configuration, True) + + load_mock.assert_not_called() + assert not isinstance(properties.reporter, WebClient) + + +def test_create_full_report_client(configuration: Configuration, mocker: MockerFixture) -> None: + """ + must create load report client if report is enabled + """ + mocker.patch("ahriman.models.repository_paths.RepositoryPaths.create_tree") + load_mock = mocker.patch("ahriman.core.status.client.Client.load") + Properties("x86_64", configuration, False) + + load_mock.assert_called_once() diff --git a/tests/ahriman/core/status/test_watcher.py b/tests/ahriman/core/status/test_watcher.py index a0dcc6ec..3a7cf764 100644 --- a/tests/ahriman/core/status/test_watcher.py +++ b/tests/ahriman/core/status/test_watcher.py @@ -5,12 +5,28 @@ from pathlib import Path from pytest_mock import MockerFixture from unittest.mock import PropertyMock +from ahriman.core.configuration import Configuration from ahriman.core.exceptions import UnknownPackage from ahriman.core.status.watcher import Watcher +from ahriman.core.status.web_client import WebClient from ahriman.models.build_status import BuildStatus, BuildStatusEnum from ahriman.models.package import Package +def test_force_no_report(configuration: Configuration, mocker: MockerFixture) -> None: + """ + must force dummy report client + """ + configuration.set_option("web", "port", "8080") + mocker.patch("pathlib.Path.mkdir") + + load_mock = mocker.patch("ahriman.core.status.client.Client.load") + watcher = Watcher("x86_64", configuration) + + load_mock.assert_not_called() + assert not isinstance(watcher.repository.reporter, WebClient) + + def test_cache_load(watcher: Watcher, package_ahriman: Package, mocker: MockerFixture) -> None: """ must load state from cache diff --git a/tests/ahriman/core/test_spawn.py b/tests/ahriman/core/test_spawn.py new file mode 100644 index 00000000..a64579d8 --- /dev/null +++ b/tests/ahriman/core/test_spawn.py @@ -0,0 +1,111 @@ +from pytest_mock import MockerFixture +from unittest.mock import MagicMock + +from ahriman.core.spawn import Spawn + + +def test_process(spawner: Spawn) -> None: + """ + must process external process run correctly + """ + args = MagicMock() + callback = MagicMock() + callback.return_value = True + + spawner.process(callback, args, spawner.architecture, "id", spawner.queue) + + callback.assert_called_with(args, spawner.architecture) + (uuid, status) = spawner.queue.get() + assert uuid == "id" + assert status + assert spawner.queue.empty() + + +def test_process_error(spawner: Spawn) -> None: + """ + must process external run with error correctly + """ + callback = MagicMock() + callback.return_value = False + + spawner.process(callback, MagicMock(), spawner.architecture, "id", spawner.queue) + + (uuid, status) = spawner.queue.get() + assert uuid == "id" + assert not status + assert spawner.queue.empty() + + +def test_packages_add(spawner: Spawn, mocker: MockerFixture) -> None: + """ + must call package addition + """ + spawn_mock = mocker.patch("ahriman.core.spawn.Spawn.spawn_process") + spawner.packages_add(["ahriman", "linux"], now=False) + spawn_mock.assert_called_with("add", "ahriman", "linux") + + +def test_packages_add_with_build(spawner: Spawn, mocker: MockerFixture) -> None: + """ + must call package addition with update + """ + spawn_mock = mocker.patch("ahriman.core.spawn.Spawn.spawn_process") + spawner.packages_add(["ahriman", "linux"], now=True) + spawn_mock.assert_called_with("add", "ahriman", "linux", now="") + + +def test_packages_remove(spawner: Spawn, mocker: MockerFixture) -> None: + """ + must call package removal + """ + spawn_mock = mocker.patch("ahriman.core.spawn.Spawn.spawn_process") + spawner.packages_remove(["ahriman", "linux"]) + spawn_mock.assert_called_with("remove", "ahriman", "linux") + + +def test_spawn_process(spawner: Spawn, mocker: MockerFixture) -> None: + """ + must correctly spawn child process + """ + start_mock = mocker.patch("multiprocessing.Process.start") + + spawner.spawn_process("add", "ahriman", now="", maybe="?") + start_mock.assert_called_once() + spawner.args_parser.parse_args.assert_called_with([ + "--architecture", spawner.architecture, "--configuration", str(spawner.configuration.path), + "add", "ahriman", "--now", "--maybe", "?" + ]) + + +def test_run(spawner: Spawn, mocker: MockerFixture) -> None: + """ + must implement run method + """ + logging_mock = mocker.patch("logging.Logger.info") + + spawner.queue.put(("1", False)) + spawner.queue.put(("2", True)) + spawner.queue.put(None) # terminate + + spawner.run() + logging_mock.assert_called() + + +def test_run_pop(spawner: Spawn) -> None: + """ + must pop and terminate child process + """ + first = spawner.active["1"] = MagicMock() + second = spawner.active["2"] = MagicMock() + + spawner.queue.put(("1", False)) + spawner.queue.put(("2", True)) + spawner.queue.put(None) # terminate + + spawner.run() + + first.terminate.assert_called_once() + first.join.assert_called_once() + second.terminate.assert_called_once() + second.join.assert_called_once() + assert not spawner.active diff --git a/tests/ahriman/core/upload/test_s3.py b/tests/ahriman/core/upload/test_s3.py index 3d286ce5..c8b3af81 100644 --- a/tests/ahriman/core/upload/test_s3.py +++ b/tests/ahriman/core/upload/test_s3.py @@ -59,10 +59,14 @@ def test_get_local_files(s3: S3, resource_path_root: Path) -> None: Path("models/package_ahriman_srcinfo"), Path("models/package_tpacpi-bat-git_srcinfo"), Path("models/package_yay_srcinfo"), + Path("web/templates/build-status/login-modal.jinja2"), + Path("web/templates/build-status/package-actions-modals.jinja2"), + Path("web/templates/build-status/package-actions-script.jinja2"), + Path("web/templates/utils/bootstrap-scripts.jinja2"), + Path("web/templates/utils/style.jinja2"), Path("web/templates/build-status.jinja2"), Path("web/templates/email-index.jinja2"), Path("web/templates/repo-index.jinja2"), - Path("web/templates/style.jinja2"), ]) local_files = list(sorted(s3.get_local_files(resource_path_root).keys())) diff --git a/tests/ahriman/models/test_user.py b/tests/ahriman/models/test_user.py index 3610e3a2..6acc2f3d 100644 --- a/tests/ahriman/models/test_user.py +++ b/tests/ahriman/models/test_user.py @@ -7,7 +7,7 @@ def test_from_option(user: User) -> None: must generate user from options """ assert User.from_option(user.username, user.password) == user - # default is status access + # default is read access user.access = UserAccess.Write assert User.from_option(user.username, user.password) != user @@ -52,17 +52,6 @@ def test_verify_access_read(user: User) -> None: user.access = UserAccess.Read assert user.verify_access(UserAccess.Read) assert not user.verify_access(UserAccess.Write) - assert not user.verify_access(UserAccess.Status) - - -def test_verify_access_status(user: User) -> None: - """ - user with status access must be able to only request status - """ - user.access = UserAccess.Status - assert not user.verify_access(UserAccess.Read) - assert not user.verify_access(UserAccess.Write) - assert user.verify_access(UserAccess.Status) def test_verify_access_write(user: User) -> None: @@ -72,4 +61,3 @@ def test_verify_access_write(user: User) -> None: user.access = UserAccess.Write assert user.verify_access(UserAccess.Read) assert user.verify_access(UserAccess.Write) - assert user.verify_access(UserAccess.Status) diff --git a/tests/ahriman/web/conftest.py b/tests/ahriman/web/conftest.py index f6919ed5..fc47d8e8 100644 --- a/tests/ahriman/web/conftest.py +++ b/tests/ahriman/web/conftest.py @@ -1,41 +1,64 @@ import pytest from aiohttp import web +from collections import namedtuple from pytest_mock import MockerFixture +from typing import Any import ahriman.core.auth.helpers from ahriman.core.configuration import Configuration +from ahriman.core.spawn import Spawn from ahriman.models.user import User from ahriman.web.web import setup_service +_request = namedtuple("_request", ["app", "path", "method", "json", "post"]) + + +@pytest.helpers.register +def request(app: web.Application, path: str, method: str, json: Any = None, data: Any = None) -> _request: + """ + request generator helper + :param app: application fixture + :param path: path for the request + :param method: method for the request + :param json: json payload of the request + :param data: form data payload of the request + :return: dummy request object + """ + return _request(app, path, method, json, data) + + @pytest.fixture -def application(configuration: Configuration, mocker: MockerFixture) -> web.Application: +def application(configuration: Configuration, spawner: Spawn, mocker: MockerFixture) -> web.Application: """ application fixture :param configuration: configuration fixture + :param spawner: spawner fixture :param mocker: mocker object :return: application test instance """ mocker.patch.object(ahriman.core.auth.helpers, "_has_aiohttp_security", False) mocker.patch("pathlib.Path.mkdir") - return setup_service("x86_64", configuration) + return setup_service("x86_64", configuration, spawner) @pytest.fixture -def application_with_auth(configuration: Configuration, user: User, mocker: MockerFixture) -> web.Application: +def application_with_auth(configuration: Configuration, user: User, spawner: Spawn, + mocker: MockerFixture) -> web.Application: """ application fixture with auth enabled :param configuration: configuration fixture :param user: user descriptor fixture + :param spawner: spawner fixture :param mocker: mocker object :return: application test instance """ configuration.set_option("auth", "target", "configuration") mocker.patch.object(ahriman.core.auth.helpers, "_has_aiohttp_security", True) mocker.patch("pathlib.Path.mkdir") - application = setup_service("x86_64", configuration) + application = setup_service("x86_64", configuration, spawner) generated = User(user.username, user.hash_password(application["validator"].salt), user.access) application["validator"]._users[generated.username] = generated diff --git a/tests/ahriman/web/middlewares/conftest.py b/tests/ahriman/web/middlewares/conftest.py index 716c29cd..a6b1a2df 100644 --- a/tests/ahriman/web/middlewares/conftest.py +++ b/tests/ahriman/web/middlewares/conftest.py @@ -1,23 +1,10 @@ import pytest -from collections import namedtuple - from ahriman.core.auth.auth import Auth from ahriman.core.configuration import Configuration from ahriman.models.user import User from ahriman.web.middlewares.auth_handler import AuthorizationPolicy -_request = namedtuple("_request", ["path", "method"]) - - -@pytest.fixture -def aiohttp_request() -> _request: - """ - fixture for aiohttp like object - :return: aiohttp like request test instance - """ - return _request("path", "GET") - @pytest.fixture def authorization_policy(configuration: Configuration, user: User) -> AuthorizationPolicy: diff --git a/tests/ahriman/web/middlewares/test_auth_handler.py b/tests/ahriman/web/middlewares/test_auth_handler.py index 96b09db8..3640889a 100644 --- a/tests/ahriman/web/middlewares/test_auth_handler.py +++ b/tests/ahriman/web/middlewares/test_auth_handler.py @@ -1,6 +1,7 @@ +import pytest + from aiohttp import web from pytest_mock import MockerFixture -from typing import Any from unittest.mock import AsyncMock, MagicMock from ahriman.core.auth.auth import Auth @@ -29,40 +30,40 @@ async def test_permits(authorization_policy: AuthorizationPolicy, user: User) -> authorization_policy.validator.verify_access.assert_called_with(user.username, user.access, "/endpoint") -async def test_auth_handler_api(aiohttp_request: Any, auth: Auth, mocker: MockerFixture) -> None: +async def test_auth_handler_api(auth: Auth, mocker: MockerFixture) -> None: """ must ask for status permission for api calls """ - aiohttp_request = aiohttp_request._replace(path="/status-api") + aiohttp_request = pytest.helpers.request("", "/status-api", "GET") request_handler = AsyncMock() mocker.patch("ahriman.core.auth.auth.Auth.is_safe_request", return_value=False) check_permission_mock = mocker.patch("aiohttp_security.check_permission") handler = auth_handler(auth) await handler(aiohttp_request, request_handler) - check_permission_mock.assert_called_with(aiohttp_request, UserAccess.Status, aiohttp_request.path) + check_permission_mock.assert_called_with(aiohttp_request, UserAccess.Read, aiohttp_request.path) -async def test_auth_handler_api_post(aiohttp_request: Any, auth: Auth, mocker: MockerFixture) -> None: +async def test_auth_handler_api_post(auth: Auth, mocker: MockerFixture) -> None: """ must ask for status permission for api calls with POST """ - aiohttp_request = aiohttp_request._replace(path="/status-api", method="POST") + aiohttp_request = pytest.helpers.request("", "/status-api", "POST") request_handler = AsyncMock() mocker.patch("ahriman.core.auth.auth.Auth.is_safe_request", return_value=False) check_permission_mock = mocker.patch("aiohttp_security.check_permission") handler = auth_handler(auth) await handler(aiohttp_request, request_handler) - check_permission_mock.assert_called_with(aiohttp_request, UserAccess.Status, aiohttp_request.path) + check_permission_mock.assert_called_with(aiohttp_request, UserAccess.Write, aiohttp_request.path) -async def test_auth_handler_read(aiohttp_request: Any, auth: Auth, mocker: MockerFixture) -> None: +async def test_auth_handler_read(auth: Auth, mocker: MockerFixture) -> None: """ must ask for read permission for api calls with GET """ for method in ("GET", "HEAD", "OPTIONS"): - aiohttp_request = aiohttp_request._replace(method=method) + aiohttp_request = pytest.helpers.request("", "", method) request_handler = AsyncMock() mocker.patch("ahriman.core.auth.auth.Auth.is_safe_request", return_value=False) check_permission_mock = mocker.patch("aiohttp_security.check_permission") @@ -72,12 +73,12 @@ async def test_auth_handler_read(aiohttp_request: Any, auth: Auth, mocker: Mocke check_permission_mock.assert_called_with(aiohttp_request, UserAccess.Read, aiohttp_request.path) -async def test_auth_handler_write(aiohttp_request: Any, auth: Auth, mocker: MockerFixture) -> None: +async def test_auth_handler_write(auth: Auth, mocker: MockerFixture) -> None: """ must ask for read permission for api calls with POST """ for method in ("CONNECT", "DELETE", "PATCH", "POST", "PUT", "TRACE"): - aiohttp_request = aiohttp_request._replace(method=method) + aiohttp_request = pytest.helpers.request("", "", method) request_handler = AsyncMock() mocker.patch("ahriman.core.auth.auth.Auth.is_safe_request", return_value=False) check_permission_mock = mocker.patch("aiohttp_security.check_permission") diff --git a/tests/ahriman/web/middlewares/test_exception_handler.py b/tests/ahriman/web/middlewares/test_exception_handler.py index 20435273..ca16d28e 100644 --- a/tests/ahriman/web/middlewares/test_exception_handler.py +++ b/tests/ahriman/web/middlewares/test_exception_handler.py @@ -3,45 +3,47 @@ import pytest from aiohttp.web_exceptions import HTTPBadRequest from pytest_mock import MockerFixture -from typing import Any from unittest.mock import AsyncMock from ahriman.web.middlewares.exception_handler import exception_handler -async def test_exception_handler(aiohttp_request: Any, mocker: MockerFixture) -> None: +async def test_exception_handler(mocker: MockerFixture) -> None: """ must pass success response """ + request = pytest.helpers.request("", "", "") request_handler = AsyncMock() logging_mock = mocker.patch("logging.Logger.exception") handler = exception_handler(logging.getLogger()) - await handler(aiohttp_request, request_handler) + await handler(request, request_handler) logging_mock.assert_not_called() -async def test_exception_handler_client_error(aiohttp_request: Any, mocker: MockerFixture) -> None: +async def test_exception_handler_client_error(mocker: MockerFixture) -> None: """ must pass client exception """ + request = pytest.helpers.request("", "", "") request_handler = AsyncMock(side_effect=HTTPBadRequest()) logging_mock = mocker.patch("logging.Logger.exception") handler = exception_handler(logging.getLogger()) with pytest.raises(HTTPBadRequest): - await handler(aiohttp_request, request_handler) + await handler(request, request_handler) logging_mock.assert_not_called() -async def test_exception_handler_server_error(aiohttp_request: Any, mocker: MockerFixture) -> None: +async def test_exception_handler_server_error(mocker: MockerFixture) -> None: """ must log server exception and re-raise it """ + request = pytest.helpers.request("", "", "") request_handler = AsyncMock(side_effect=Exception()) logging_mock = mocker.patch("logging.Logger.exception") handler = exception_handler(logging.getLogger()) with pytest.raises(Exception): - await handler(aiohttp_request, request_handler) + await handler(request, request_handler) logging_mock.assert_called_once() diff --git a/tests/ahriman/web/views/conftest.py b/tests/ahriman/web/views/conftest.py index 8ae6bcc9..0ced73e5 100644 --- a/tests/ahriman/web/views/conftest.py +++ b/tests/ahriman/web/views/conftest.py @@ -6,6 +6,18 @@ from pytest_aiohttp import TestClient from pytest_mock import MockerFixture from typing import Any +from ahriman.web.views.base import BaseView + + +@pytest.fixture +def base(application: web.Application) -> BaseView: + """ + base view fixture + :param application: application fixture + :return: generated base view fixture + """ + return BaseView(pytest.helpers.request(application, "", "")) + @pytest.fixture def client(application: web.Application, loop: BaseEventLoop, diff --git a/tests/ahriman/web/views/service/test_views_service_add.py b/tests/ahriman/web/views/service/test_views_service_add.py new file mode 100644 index 00000000..24c750c9 --- /dev/null +++ b/tests/ahriman/web/views/service/test_views_service_add.py @@ -0,0 +1,46 @@ +from aiohttp.test_utils import TestClient +from pytest_mock import MockerFixture + + +async def test_post(client: TestClient, mocker: MockerFixture) -> None: + """ + must call post request correctly + """ + add_mock = mocker.patch("ahriman.core.spawn.Spawn.packages_add") + response = await client.post("/service-api/v1/add", json={"packages": ["ahriman"]}) + + assert response.status == 200 + add_mock.assert_called_with(["ahriman"], True) + + +async def test_post_now(client: TestClient, mocker: MockerFixture) -> None: + """ + must call post and run build + """ + add_mock = mocker.patch("ahriman.core.spawn.Spawn.packages_add") + response = await client.post("/service-api/v1/add", json={"packages": ["ahriman"], "build_now": False}) + + assert response.status == 200 + add_mock.assert_called_with(["ahriman"], False) + + +async def test_post_exception(client: TestClient, mocker: MockerFixture) -> None: + """ + must raise exception on missing packages payload + """ + add_mock = mocker.patch("ahriman.core.spawn.Spawn.packages_add") + response = await client.post("/service-api/v1/add") + + assert response.status == 400 + add_mock.assert_not_called() + + +async def test_post_update(client: TestClient, mocker: MockerFixture) -> None: + """ + must call post request correctly for alias + """ + add_mock = mocker.patch("ahriman.core.spawn.Spawn.packages_add") + response = await client.post("/service-api/v1/update", json={"packages": ["ahriman"]}) + + assert response.status == 200 + add_mock.assert_called_with(["ahriman"], True) diff --git a/tests/ahriman/web/views/service/test_views_service_remove.py b/tests/ahriman/web/views/service/test_views_service_remove.py new file mode 100644 index 00000000..d7c45d80 --- /dev/null +++ b/tests/ahriman/web/views/service/test_views_service_remove.py @@ -0,0 +1,24 @@ +from aiohttp.test_utils import TestClient +from pytest_mock import MockerFixture + + +async def test_post(client: TestClient, mocker: MockerFixture) -> None: + """ + must call post request correctly + """ + add_mock = mocker.patch("ahriman.core.spawn.Spawn.packages_remove") + response = await client.post("/service-api/v1/remove", json={"packages": ["ahriman"]}) + + assert response.status == 200 + add_mock.assert_called_with(["ahriman"]) + + +async def test_post_exception(client: TestClient, mocker: MockerFixture) -> None: + """ + must raise exception on missing packages payload + """ + add_mock = mocker.patch("ahriman.core.spawn.Spawn.packages_remove") + response = await client.post("/service-api/v1/remove") + + assert response.status == 400 + add_mock.assert_not_called() diff --git a/tests/ahriman/web/views/service/test_views_service_search.py b/tests/ahriman/web/views/service/test_views_service_search.py new file mode 100644 index 00000000..bfd3158d --- /dev/null +++ b/tests/ahriman/web/views/service/test_views_service_search.py @@ -0,0 +1,59 @@ +import aur + +from aiohttp.test_utils import TestClient +from pytest_mock import MockerFixture + + +async def test_get(client: TestClient, aur_package_ahriman: aur.Package, mocker: MockerFixture) -> None: + """ + must call get request correctly + """ + mocker.patch("aur.search", return_value=[aur_package_ahriman]) + response = await client.get("/service-api/v1/search", params={"for": "ahriman"}) + + assert response.status == 200 + assert await response.json() == ["ahriman"] + + +async def test_get_exception(client: TestClient, mocker: MockerFixture) -> None: + """ + must raise 400 on empty search string + """ + search_mock = mocker.patch("aur.search") + response = await client.get("/service-api/v1/search") + + assert response.status == 400 + search_mock.assert_not_called() + + +async def test_get_join(client: TestClient, mocker: MockerFixture) -> None: + """ + must join search args with space + """ + search_mock = mocker.patch("aur.search") + response = await client.get("/service-api/v1/search", params=[("for", "ahriman"), ("for", "maybe")]) + + assert response.status == 200 + search_mock.assert_called_with("ahriman maybe") + + +async def test_get_join_filter(client: TestClient, mocker: MockerFixture) -> None: + """ + must filter search parameters with less than 3 symbols + """ + search_mock = mocker.patch("aur.search") + response = await client.get("/service-api/v1/search", params=[("for", "ah"), ("for", "maybe")]) + + assert response.status == 200 + search_mock.assert_called_with("maybe") + + +async def test_get_join_filter_empty(client: TestClient, mocker: MockerFixture) -> None: + """ + must filter search parameters with less than 3 symbols (empty result) + """ + search_mock = mocker.patch("aur.search") + response = await client.get("/service-api/v1/search", params=[("for", "ah"), ("for", "ma")]) + + assert response.status == 400 + search_mock.assert_not_called() diff --git a/tests/ahriman/web/views/test_view_ahriman.py b/tests/ahriman/web/views/status/test_views_status_ahriman.py similarity index 100% rename from tests/ahriman/web/views/test_view_ahriman.py rename to tests/ahriman/web/views/status/test_views_status_ahriman.py diff --git a/tests/ahriman/web/views/test_view_package.py b/tests/ahriman/web/views/status/test_views_status_package.py similarity index 100% rename from tests/ahriman/web/views/test_view_package.py rename to tests/ahriman/web/views/status/test_views_status_package.py diff --git a/tests/ahriman/web/views/test_view_packages.py b/tests/ahriman/web/views/status/test_views_status_packages.py similarity index 100% rename from tests/ahriman/web/views/test_view_packages.py rename to tests/ahriman/web/views/status/test_views_status_packages.py diff --git a/tests/ahriman/web/views/test_view_status.py b/tests/ahriman/web/views/status/test_views_status_status.py similarity index 100% rename from tests/ahriman/web/views/test_view_status.py rename to tests/ahriman/web/views/status/test_views_status_status.py diff --git a/tests/ahriman/web/views/test_views_base.py b/tests/ahriman/web/views/test_views_base.py new file mode 100644 index 00000000..e0ff3117 --- /dev/null +++ b/tests/ahriman/web/views/test_views_base.py @@ -0,0 +1,88 @@ +import pytest + +from multidict import MultiDict + +from ahriman.web.views.base import BaseView + + +def test_service(base: BaseView) -> None: + """ + must return service + """ + assert base.service + + +def test_spawn(base: BaseView) -> None: + """ + must return spawn thread + """ + assert base.spawner + + +def test_validator(base: BaseView) -> None: + """ + must return service + """ + assert base.validator + + +async def test_extract_data_json(base: BaseView) -> None: + """ + must parse and return json + """ + json = {"key1": "value1", "key2": "value2"} + + async def get_json(): + return json + + base._request = pytest.helpers.request(base.request.app, "", "", json=get_json) + assert await base.extract_data() == json + + +async def test_extract_data_post(base: BaseView) -> None: + """ + must parse and return form data + """ + json = {"key1": "value1", "key2": "value2"} + + async def get_json(): + raise ValueError() + + async def get_data(): + return json + + base._request = pytest.helpers.request(base.request.app, "", "", json=get_json, data=get_data) + assert await base.extract_data() == json + + +async def test_data_as_json(base: BaseView) -> None: + """ + must parse multi value form payload + """ + json = {"key1": "value1", "key2": ["value2", "value3"], "key3": ["value4", "value5", "value6"]} + + async def get_data(): + result = MultiDict() + for key, values in json.items(): + if isinstance(values, list): + for value in values: + result.add(key, value) + else: + result.add(key, values) + return result + + base._request = pytest.helpers.request(base.request.app, "", "", data=get_data) + assert await base.data_as_json([]) == json + + +async def test_data_as_json_with_list_keys(base: BaseView) -> None: + """ + must parse multi value form payload with forced list + """ + json = {"key1": "value1"} + + async def get_data(): + return json + + base._request = pytest.helpers.request(base.request.app, "", "", data=get_data) + assert await base.data_as_json(["key1"]) == {"key1": ["value1"]} diff --git a/tests/ahriman/web/views/test_view_index.py b/tests/ahriman/web/views/test_views_index.py similarity index 100% rename from tests/ahriman/web/views/test_view_index.py rename to tests/ahriman/web/views/test_views_index.py diff --git a/tests/ahriman/web/views/test_view_login.py b/tests/ahriman/web/views/user/test_views_user_login.py similarity index 77% rename from tests/ahriman/web/views/test_view_login.py rename to tests/ahriman/web/views/user/test_views_user_login.py index 289b1fc2..8be422f1 100644 --- a/tests/ahriman/web/views/test_view_login.py +++ b/tests/ahriman/web/views/user/test_views_user_login.py @@ -11,10 +11,10 @@ async def test_post(client_with_auth: TestClient, user: User, mocker: MockerFixt payload = {"username": user.username, "password": user.password} remember_mock = mocker.patch("aiohttp_security.remember") - post_response = await client_with_auth.post("/login", json=payload) + post_response = await client_with_auth.post("/user-api/v1/login", json=payload) assert post_response.status == 200 - post_response = await client_with_auth.post("/login", data=payload) + post_response = await client_with_auth.post("/user-api/v1/login", data=payload) assert post_response.status == 200 remember_mock.assert_called() @@ -25,7 +25,7 @@ async def test_post_skip(client: TestClient, user: User) -> None: must process if no auth configured """ payload = {"username": user.username, "password": user.password} - post_response = await client.post("/login", json=payload) + post_response = await client.post("/user-api/v1/login", json=payload) assert post_response.status == 200 @@ -36,6 +36,6 @@ async def test_post_unauthorized(client_with_auth: TestClient, user: User, mocke payload = {"username": user.username, "password": ""} remember_mock = mocker.patch("aiohttp_security.remember") - post_response = await client_with_auth.post("/login", json=payload) + post_response = await client_with_auth.post("/user-api/v1/login", json=payload) assert post_response.status == 401 remember_mock.assert_not_called() diff --git a/tests/ahriman/web/views/test_view_logout.py b/tests/ahriman/web/views/user/test_views_user_logout.py similarity index 82% rename from tests/ahriman/web/views/test_view_logout.py rename to tests/ahriman/web/views/user/test_views_user_logout.py index d9135c4a..3d287bb0 100644 --- a/tests/ahriman/web/views/test_view_logout.py +++ b/tests/ahriman/web/views/user/test_views_user_logout.py @@ -10,7 +10,7 @@ async def test_post(client_with_auth: TestClient, mocker: MockerFixture) -> None mocker.patch("aiohttp_security.check_authorized") forget_mock = mocker.patch("aiohttp_security.forget") - post_response = await client_with_auth.post("/logout") + post_response = await client_with_auth.post("/user-api/v1/logout") assert post_response.status == 200 forget_mock.assert_called_once() @@ -22,7 +22,7 @@ async def test_post_unauthorized(client_with_auth: TestClient, mocker: MockerFix mocker.patch("aiohttp_security.check_authorized", side_effect=HTTPUnauthorized()) forget_mock = mocker.patch("aiohttp_security.forget") - post_response = await client_with_auth.post("/logout") + post_response = await client_with_auth.post("/user-api/v1/logout") assert post_response.status == 401 forget_mock.assert_not_called() @@ -31,5 +31,5 @@ async def test_post_disabled(client: TestClient) -> None: """ must raise exception if auth is disabled """ - post_response = await client.post("/logout") + post_response = await client.post("/user-api/v1/logout") assert post_response.status == 200
packagepackage versionarchitecturedescriptionupstream urllicensesgroupsdepends archive size installed size build date
{{ package.name }} {{ package.version }}{{ package.architecture }}{{ package.description }}{{ package.url }}{{ package.licenses|join("
"|safe) }}
{{ package.groups|join("
"|safe) }}
{{ package.depends|join("
"|safe) }}
{{ package.archive_size }} {{ package.installed_size }} {{ package.build_date }}