From 070d1d6d621c081561437809781b1b8532d4c24b Mon Sep 17 00:00:00 2001 From: Evgeniy Alekseev Date: Fri, 5 May 2023 16:22:53 +0300 Subject: [PATCH] implement keyring package generator --- docs/configuration.rst | 18 ++ package/share/ahriman/settings/ahriman.ini | 5 +- src/ahriman/application/ahriman.py | 20 ++ src/ahriman/core/exceptions.py | 12 ++ src/ahriman/core/report/html.py | 2 +- .../core/repository/repository_properties.py | 2 +- src/ahriman/core/sign/gpg.py | 49 ++++- src/ahriman/core/support/__init__.py | 1 + src/ahriman/core/support/keyring_trigger.py | 114 ++++++++++ src/ahriman/core/support/package_creator.py | 3 +- .../support/pkgbuild/keyring_generator.py | 194 ++++++++++++++++++ .../support/pkgbuild/mirrorlist_generator.py | 6 +- .../support/pkgbuild/pkgbuild_generator.py | 30 ++- .../handlers/test_handler_validate.py | 2 + tests/ahriman/application/test_ahriman.py | 18 ++ tests/ahriman/core/conftest.py | 15 ++ tests/ahriman/core/report/test_html.py | 2 +- tests/ahriman/core/sign/conftest.py | 15 -- tests/ahriman/core/sign/test_gpg.py | 42 ++++ .../ahriman/core/support/pkgbuild/conftest.py | 18 ++ .../pkgbuild/test_keyring_generator.py | 185 +++++++++++++++++ .../pkgbuild/test_mirrorlist_generator.py | 13 +- .../pkgbuild/test_pkgbuild_generator.py | 31 ++- .../core/support/test_keyring_trigger.py | 30 +++ .../core/support/test_mirrorlist_trigger.py | 1 - tests/testresources/core/ahriman.ini | 5 +- 26 files changed, 790 insertions(+), 43 deletions(-) create mode 100644 src/ahriman/core/support/keyring_trigger.py create mode 100644 src/ahriman/core/support/pkgbuild/keyring_generator.py create mode 100644 tests/ahriman/core/support/pkgbuild/test_keyring_generator.py create mode 100644 tests/ahriman/core/support/test_keyring_trigger.py diff --git a/docs/configuration.rst b/docs/configuration.rst index a4fcfc3a..c57e80b5 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -108,6 +108,24 @@ Web server settings. If any of ``host``/``port`` is not set, web integration wil * ``unix_socket_unsafe`` - set unsafe (o+w) permissions to unix socket, boolean, optional, default ``yes``. This option is enabled by default, because it is supposed that unix socket is created in safe environment (only web service is supposed to be used in unsafe), but it can be disabled by configuration. * ``username`` - username to authorize in web service in order to update service status, string, required in case if authorization enabled. +``keyring`` group +-------------------- + +Keyring package generator plugin. + +* ``target`` - list of generator settings sections, space separated list of strings, required. It must point to valid section name. + +Keyring generator plugin +^^^^^^^^^^^^^^^^^^^^^^^^ + +* ``description`` - keyring package description, string, optional, default is ``repo PGP keyring``, where ``repo`` is the repository name. +* ``homepage`` - url to homepage location if any, string, optional. +* ``license`` - list of licenses which are applied to this package, space separated list of strings, optional, default is ``Unlicense``. +* ``package`` - keyring package name, string, optional, default is ``repo-keyring``, where ``repo`` is the repository name. +* ``packagers`` - list of packagers keys, space separated list of strings, optional, if not set, the ``key_*`` options from ``sign`` group will be used. +* ``revoked`` - list of revoked packagers keys, space separated list of strings, optional. +* ``trusted`` - list of master keys, space separated list of strings, optional, if not set, the ``key`` option from ``sign`` group will be used. + ``mirrorlist`` group -------------------- diff --git a/package/share/ahriman/settings/ahriman.ini b/package/share/ahriman/settings/ahriman.ini index 8ae1f406..0588bdc6 100644 --- a/package/share/ahriman/settings/ahriman.ini +++ b/package/share/ahriman/settings/ahriman.ini @@ -25,7 +25,7 @@ ignore_packages = makechrootpkg_flags = makepkg_flags = --nocolor --ignorearch triggers = ahriman.core.gitremote.RemotePullTrigger ahriman.core.report.ReportTrigger ahriman.core.upload.UploadTrigger ahriman.core.gitremote.RemotePushTrigger -triggers_known = ahriman.core.support.MirrorlistTrigger +triggers_known = ahriman.core.support.KeyringTrigger ahriman.core.support.MirrorlistTrigger vcs_allowed_age = 604800 [repository] @@ -35,6 +35,9 @@ root = /var/lib/ahriman [sign] target = +[keyring] +target = + [mirrorlist] target = diff --git a/src/ahriman/application/ahriman.py b/src/ahriman/application/ahriman.py index 52388f5e..4c8aae35 100644 --- a/src/ahriman/application/ahriman.py +++ b/src/ahriman/application/ahriman.py @@ -100,6 +100,7 @@ def _parser() -> argparse.ArgumentParser: _set_patch_set_add_parser(subparsers) _set_repo_backup_parser(subparsers) _set_repo_check_parser(subparsers) + _set_repo_create_keyring_parser(subparsers) _set_repo_create_mirrorlist_parser(subparsers) _set_repo_daemon_parser(subparsers) _set_repo_rebuild_parser(subparsers) @@ -479,6 +480,25 @@ def _set_repo_check_parser(root: SubParserAction) -> argparse.ArgumentParser: return parser +def _set_repo_create_keyring_parser(root: SubParserAction) -> argparse.ArgumentParser: + """ + add parser for create-keyring subcommand + + Args: + root(SubParserAction): subparsers for the commands + + Returns: + argparse.ArgumentParser: created argument parser + """ + parser = root.add_parser("repo-create-keyring", help="create keyring package", + description="create package which contains list of trusted keys as set by " + "configuration. Note, that this action will only create package, the package " + "itself has to be built manually", + formatter_class=_formatter) + parser.set_defaults(handler=handlers.Triggers, trigger=["ahriman.core.support.KeyringTrigger"]) + return parser + + def _set_repo_create_mirrorlist_parser(root: SubParserAction) -> argparse.ArgumentParser: """ add parser for create-mirrorlist subcommand diff --git a/src/ahriman/core/exceptions.py b/src/ahriman/core/exceptions.py index 808e2bd2..e750efc9 100644 --- a/src/ahriman/core/exceptions.py +++ b/src/ahriman/core/exceptions.py @@ -194,6 +194,18 @@ class PasswordError(ValueError): ValueError.__init__(self, f"Password error: {details}") +class PkgbuildGeneratorError(RuntimeError): + """ + exception class for support type triggers + """ + + def __init__(self) -> None: + """ + default constructor + """ + RuntimeError.__init__(self, "Could not generate package") + + class ReportError(RuntimeError): """ report generation exception diff --git a/src/ahriman/core/report/html.py b/src/ahriman/core/report/html.py index 8c5edf13..ffc1ff08 100644 --- a/src/ahriman/core/report/html.py +++ b/src/ahriman/core/report/html.py @@ -57,4 +57,4 @@ class HTML(Report, JinjaTemplate): result(Result): build result """ html = self.make_html(Result(success=packages), self.template_path) - self.report_path.write_text(html) + self.report_path.write_text(html, encoding="utf8") diff --git a/src/ahriman/core/repository/repository_properties.py b/src/ahriman/core/repository/repository_properties.py index a07849b5..2a236f53 100644 --- a/src/ahriman/core/repository/repository_properties.py +++ b/src/ahriman/core/repository/repository_properties.py @@ -79,7 +79,7 @@ class RepositoryProperties(LazyLogging): self.ignore_list = configuration.getlist("build", "ignore_packages", fallback=[]) self.pacman = Pacman(architecture, configuration, refresh_database=refresh_pacman_database) - self.sign = GPG(architecture, configuration) + self.sign = GPG(configuration) self.repo = Repo(self.name, self.paths, self.sign.repository_sign_args) self.reporter = Client.load(configuration, report=report) self.triggers = TriggerLoader.load(architecture, configuration) diff --git a/src/ahriman/core/sign/gpg.py b/src/ahriman/core/sign/gpg.py index 976fac7e..be167c2c 100644 --- a/src/ahriman/core/sign/gpg.py +++ b/src/ahriman/core/sign/gpg.py @@ -19,6 +19,7 @@ # import requests +from collections.abc import Generator from pathlib import Path from ahriman.core.configuration import Configuration @@ -34,7 +35,6 @@ class GPG(LazyLogging): Attributes: DEFAULT_TIMEOUT(int): (class attribute) HTTP request timeout in seconds - architecture(str): repository architecture configuration(Configuration): configuration instance default_key(str | None): default PGP key ID to use targets(set[SignSettings]): list of targets to sign (repository, package etc) @@ -43,15 +43,13 @@ class GPG(LazyLogging): _check_output = check_output DEFAULT_TIMEOUT = 30 - def __init__(self, architecture: str, configuration: Configuration) -> None: + def __init__(self, configuration: Configuration) -> None: """ default constructor Args: - architecture(str): repository architecture configuration(Configuration): configuration instance """ - self.architecture = architecture self.configuration = configuration self.targets, self.default_key = self.sign_options(configuration) @@ -128,6 +126,34 @@ class GPG(LazyLogging): raise return response.text + def key_export(self, key: str) -> str: + """ + export public key from stored keychain + + Args: + key(str): key ID to export + + Returns: + str: PGP key in .asc format + """ + return GPG._check_output("gpg", "--armor", "--no-emit-version", "--export", key, logger=self.logger) + + def key_fingerprint(self, key: str) -> str: + """ + get full key fingerprint from short key id + + Args: + key(str): key ID to lookup + + Returns: + str: full PGP key fingerprint + """ + metadata = GPG._check_output("gpg", "--with-colons", "--fingerprint", key, logger=self.logger) + # fingerprint line will be like + # fpr:::::::::43A663569A07EE1E4ECC55CC7E3A4240CE3C45C2: + fingerprint = next(filter(lambda line: line[:3] == "fpr", metadata.splitlines())) + return fingerprint.split(":")[-2] + def key_import(self, server: str, key: str) -> None: """ import key to current user and sign it locally @@ -139,6 +165,21 @@ class GPG(LazyLogging): key_body = self.key_download(server, key) GPG._check_output("gpg", "--import", input_data=key_body, logger=self.logger) + def keys(self) -> list[str]: + """ + extract list of keys described in configuration + + Returns: + list[str]: list of unique keys which are set in configuration + """ + def generator() -> Generator[str, None, None]: + if self.default_key is not None: + yield self.default_key + for _, value in filter(lambda pair: pair[0].startswith("key_"), self.configuration["sign"].items()): + yield value + + return sorted(set(generator())) + def process(self, path: Path, key: str) -> list[Path]: """ gpg command wrapper diff --git a/src/ahriman/core/support/__init__.py b/src/ahriman/core/support/__init__.py index d8f2df96..f607effe 100644 --- a/src/ahriman/core/support/__init__.py +++ b/src/ahriman/core/support/__init__.py @@ -17,4 +17,5 @@ # You should have received a copy of the GNU General Public License # along with this program. If not, see . # +from ahriman.core.support.keyring_trigger import KeyringTrigger from ahriman.core.support.mirrorlist_trigger import MirrorlistTrigger diff --git a/src/ahriman/core/support/keyring_trigger.py b/src/ahriman/core/support/keyring_trigger.py new file mode 100644 index 00000000..6a83e889 --- /dev/null +++ b/src/ahriman/core/support/keyring_trigger.py @@ -0,0 +1,114 @@ +# +# Copyright (c) 2021-2023 ahriman team. +# +# This file is part of ahriman +# (see https://github.com/arcan1s/ahriman). +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +from ahriman.core import context +from ahriman.core.configuration import Configuration +from ahriman.core.sign.gpg import GPG +from ahriman.core.support.package_creator import PackageCreator +from ahriman.core.support.pkgbuild.keyring_generator import KeyringGenerator +from ahriman.core.triggers import Trigger +from ahriman.models.context_key import ContextKey + + +class KeyringTrigger(Trigger): + """ + keyring generator trigger + + Attributes: + targets(list[str]): git remote target list + """ + + CONFIGURATION_SCHEMA = { + "keyring": { + "type": "dict", + "schema": { + "target": { + "type": "list", + "coerce": "list", + "schema": {"type": "string"}, + }, + }, + }, + "keyring_generator": { + "type": "dict", + "schema": { + "description": { + "type": "string", + }, + "homepage": { + "type": "string", + }, + "license": { + "type": "list", + "coerce": "list", + }, + "package": { + "type": "string", + }, + "packagers": { + "type": "list", + "coerce": "list", + }, + "revoked": { + "type": "list", + "coerce": "list", + }, + "trusted": { + "type": "list", + "coerce": "list", + }, + }, + }, + } + + def __init__(self, architecture: str, configuration: Configuration) -> None: + """ + default constructor + + Args: + architecture(str): repository architecture + configuration(Configuration): configuration instance + """ + Trigger.__init__(self, architecture, configuration) + self.targets = self.configuration_sections(configuration) + + @classmethod + def configuration_sections(cls, configuration: Configuration) -> list[str]: + """ + extract configuration sections from configuration + + Args: + configuration(Configuration): configuration instance + + Returns: + list[str]: read configuration sections belong to this trigger + """ + return configuration.getlist("keyring", "target", fallback=[]) + + def on_start(self) -> None: + """ + trigger action which will be called at the start of the application + """ + ctx = context.get() + sign = ctx.get(ContextKey("sign", GPG)) + + for target in self.targets: + generator = KeyringGenerator(sign, self.configuration, target) + runner = PackageCreator(self.configuration, generator) + runner.run() diff --git a/src/ahriman/core/support/package_creator.py b/src/ahriman/core/support/package_creator.py index 61dfad73..6ab120df 100644 --- a/src/ahriman/core/support/package_creator.py +++ b/src/ahriman/core/support/package_creator.py @@ -23,14 +23,13 @@ from ahriman.core import context from ahriman.core.build_tools.sources import Sources from ahriman.core.configuration import Configuration from ahriman.core.database import SQLite -from ahriman.core.log import LazyLogging from ahriman.core.support.pkgbuild.pkgbuild_generator import PkgbuildGenerator from ahriman.models.build_status import BuildStatus from ahriman.models.context_key import ContextKey from ahriman.models.package import Package -class PackageCreator(LazyLogging): +class PackageCreator: """ helper which creates packages based on pkgbuild generator diff --git a/src/ahriman/core/support/pkgbuild/keyring_generator.py b/src/ahriman/core/support/pkgbuild/keyring_generator.py new file mode 100644 index 00000000..0ddb8c3f --- /dev/null +++ b/src/ahriman/core/support/pkgbuild/keyring_generator.py @@ -0,0 +1,194 @@ +# +# Copyright (c) 2021-2023 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 collections.abc import Callable +from pathlib import Path + +from ahriman.core.configuration import Configuration +from ahriman.core.exceptions import PkgbuildGeneratorError +from ahriman.core.sign.gpg import GPG +from ahriman.core.support.pkgbuild.pkgbuild_generator import PkgbuildGenerator + + +class KeyringGenerator(PkgbuildGenerator): + """ + generator for keyring PKGBUILD + + Attributes: + sign(GPG): GPG wrapper instance + name(str): repository name + packagers(list[str]): list of packagers PGP keys + pkgbuild_license(list[str]): keyring package license + pkgbuild_pkgdesc(str): keyring package description + pkgbuild_pkgname(str): keyring package name + pkgbuild_url(str): keyring package home page + revoked(list[str]): list of revoked PGP keys + trusted(list[str]): lif of trusted PGP keys + """ + + def __init__(self, sign: GPG, configuration: Configuration, section: str) -> None: + """ + default constructor + + Args: + sign(GPG): GPG wrapper instance + configuration(Configuration): configuration instance + section(str): settings section name + """ + self.sign = sign + self.name = configuration.repository_name + + # configuration fields + self.packagers = configuration.getlist(section, "packagers", fallback=sign.keys()) + self.revoked = configuration.getlist(section, "revoked", fallback=[]) + self.trusted = configuration.getlist( + section, "trusted", fallback=[sign.default_key] if sign.default_key is not None else []) + # pkgbuild description fields + self.pkgbuild_pkgname = configuration.get(section, "package", fallback=f"{self.name}-keyring") + self.pkgbuild_pkgdesc = configuration.get(section, "description", fallback=f"{self.name} PGP keyring") + self.pkgbuild_license = configuration.getlist(section, "license", fallback=["Unlicense"]) + self.pkgbuild_url = configuration.get(section, "homepage", fallback="") + + @property + def license(self) -> list[str]: + """ + package licenses list + + Returns: + list[str]: package licenses as PKGBUILD property + """ + return self.pkgbuild_license + + @property + def pkgdesc(self) -> str: + """ + package description + + Returns: + str: package description as PKGBUILD property + """ + return self.pkgbuild_pkgdesc + + @property + def pkgname(self) -> str: + """ + package name + + Returns: + str: package name as PKGBUILD property + """ + return self.pkgbuild_pkgname + + @property + def url(self) -> str: + """ + package upstream url + + Returns: + str: package upstream url as PKGBUILD property + """ + return self.pkgbuild_url + + def _generate_gpg(self, source_path: Path) -> None: + """ + generate GPG keychain + + Args: + source_path(Path): destination of the file content + """ + with source_path.open("w") as source_file: + for key in sorted(set(self.trusted + self.packagers + self.revoked)): + public_key = self.sign.key_export(key) + source_file.write(public_key) + source_file.write("\n") + + def _generate_revoked(self, source_path: Path) -> None: + """ + generate revoked PGP keys + + Args: + source_path(Path): destination of the file content + """ + with source_path.open("w") as source_file: + for key in sorted(set(self.revoked)): + fingerprint = self.sign.key_fingerprint(key) + source_file.write(fingerprint) + source_file.write("\n") + + def _generate_trusted(self, source_path: Path) -> None: + """ + generate trusted PGP keys + + Args: + source_path(Path): destination of the file content + """ + if not self.trusted: + raise PkgbuildGeneratorError + with source_path.open("w") as source_file: + for key in sorted(set(self.trusted)): + fingerprint = self.sign.key_fingerprint(key) + source_file.write(fingerprint) + source_file.write(":4:\n") + + def install(self) -> str | None: + """ + content of the install functions + + Returns: + str | None: content of the install functions if any + """ + # copy-paste from archlinux-keyring + return f"""post_upgrade() {{ + if usr/bin/pacman-key -l >/dev/null 2>&1; then + usr/bin/pacman-key --populate {self.name} + usr/bin/pacman-key --updatedb + fi +}} + +post_install() {{ + if [ -x usr/bin/pacman-key ]; then + post_upgrade + fi +}}""" + + def package(self) -> str: + """ + package function generator + + Returns: + str: package() function for PKGBUILD + """ + return f"""{{ + install -Dm644 "{Path("$srcdir") / f"{self.name}.gpg"}" "{Path("$pkgdir") / "usr" / "share" / "pacman" / "keyrings" / f"{self.name}.gpg"}" + install -Dm644 "{Path("$srcdir") / f"{self.name}-revoked"}" "{Path("$pkgdir") / "usr" / "share" / "pacman" / "keyrings" / f"{self.name}-revoked"}" + install -Dm644 "{Path("$srcdir") / f"{self.name}-trusted"}" "{Path("$pkgdir") / "usr" / "share" / "pacman" / "keyrings" / f"{self.name}-trusted"}" +}}""" + + def sources(self) -> dict[str, Callable[[Path], None]]: + """ + return list of sources for the package + + Returns: + dict[str, Callable[[Path], None]]: map of source identifier (e.g. filename) to its generator function + """ + return { + f"{self.name}.gpg": self._generate_gpg, + f"{self.name}-revoked": self._generate_revoked, + f"{self.name}-trusted": self._generate_trusted, + } diff --git a/src/ahriman/core/support/pkgbuild/mirrorlist_generator.py b/src/ahriman/core/support/pkgbuild/mirrorlist_generator.py index 9f8983b5..36f3df08 100644 --- a/src/ahriman/core/support/pkgbuild/mirrorlist_generator.py +++ b/src/ahriman/core/support/pkgbuild/mirrorlist_generator.py @@ -106,8 +106,8 @@ class MirrorlistGenerator(PkgbuildGenerator): Args: source_path(Path): destination of the mirrorlist content """ - with source_path.open("w") as source_file: - source_file.writelines([f"Server = {server}\n" for server in self.servers]) + content = "".join([f"Server = {server}\n" for server in self.servers]) + source_path.write_text(content, encoding="utf8") def package(self) -> str: """ @@ -139,5 +139,5 @@ class MirrorlistGenerator(PkgbuildGenerator): dict[str, Callable[[Path], None]]: map of source identifier (e.g. filename) to its generator function """ return { - "mirrorlist": self._generate_mirrorlist + "mirrorlist": self._generate_mirrorlist, } diff --git a/src/ahriman/core/support/pkgbuild/pkgbuild_generator.py b/src/ahriman/core/support/pkgbuild/pkgbuild_generator.py index 60cbfdcb..42787e90 100644 --- a/src/ahriman/core/support/pkgbuild/pkgbuild_generator.py +++ b/src/ahriman/core/support/pkgbuild/pkgbuild_generator.py @@ -23,12 +23,11 @@ import itertools from collections.abc import Callable, Generator from pathlib import Path -from ahriman.core.log import LazyLogging from ahriman.core.util import utcnow from ahriman.models.pkgbuild_patch import PkgbuildPatch -class PkgbuildGenerator(LazyLogging): +class PkgbuildGenerator: """ main class for generating PKGBUILDs @@ -97,6 +96,14 @@ class PkgbuildGenerator(LazyLogging): """ return "" + def install(self) -> str | None: + """ + content of the install functions + + Returns: + str | None: content of the install functions if any + """ + def package(self) -> str: """ package function generator @@ -127,6 +134,24 @@ class PkgbuildGenerator(LazyLogging): """ return {} + def write_install(self, source_dir: Path) -> list[PkgbuildPatch]: + """ + generate content of install file + + Args: + source_dir(Path): path to directory in which sources must be generated + + Returns: + list[PkgbuildPatch]: patch for the pkgbuild if install file exists and empty list otherwise + """ + content: str | None = self.install() + if content is None: + return [] + + source_path = source_dir / f"{self.pkgname}.install" + source_path.write_text(content) + return [PkgbuildPatch("install", source_path.name)] + def write_pkgbuild(self, source_dir: Path) -> None: """ generate PKGBUILD content to the specified path @@ -143,6 +168,7 @@ class PkgbuildGenerator(LazyLogging): PkgbuildPatch("url", self.url), ]) # ...main properties as defined by derived class... patches.extend(self.patches()) # ...optional properties as defined by derived class... + patches.extend(self.write_install(source_dir)) # ...install function... patches.append(PkgbuildPatch("package()", self.package())) # ...package function... patches.extend(self.write_sources(source_dir)) # ...and finally source files diff --git a/tests/ahriman/application/handlers/test_handler_validate.py b/tests/ahriman/application/handlers/test_handler_validate.py index 237d0bdd..3bfb7d0d 100644 --- a/tests/ahriman/application/handlers/test_handler_validate.py +++ b/tests/ahriman/application/handlers/test_handler_validate.py @@ -62,6 +62,8 @@ def test_schema(configuration: Configuration) -> None: assert schema.pop("email") assert schema.pop("github") assert schema.pop("html") + assert schema.pop("keyring") + assert schema.pop("keyring_generator") assert schema.pop("mirrorlist") assert schema.pop("mirrorlist_generator") assert schema.pop("report") diff --git a/tests/ahriman/application/test_ahriman.py b/tests/ahriman/application/test_ahriman.py index a03caae8..f5af76fd 100644 --- a/tests/ahriman/application/test_ahriman.py +++ b/tests/ahriman/application/test_ahriman.py @@ -373,6 +373,24 @@ def test_subparsers_repo_check_option_refresh(parser: argparse.ArgumentParser) - assert args.refresh == 2 +def test_subparsers_repo_create_keyring(parser: argparse.ArgumentParser) -> None: + """ + repo-create-keyring command must imply trigger + """ + args = parser.parse_args(["repo-create-keyring"]) + assert args.trigger == ["ahriman.core.support.KeyringTrigger"] + + +def test_subparsers_repo_create_keyring_architecture(parser: argparse.ArgumentParser) -> None: + """ + repo-create-keyring command must correctly parse architecture list + """ + args = parser.parse_args(["repo-create-keyring"]) + assert args.architecture is None + args = parser.parse_args(["-a", "x86_64", "repo-create-keyring"]) + assert args.architecture == ["x86_64"] + + def test_subparsers_repo_create_mirrorlist(parser: argparse.ArgumentParser) -> None: """ repo-create-mirrorlist command must imply trigger diff --git a/tests/ahriman/core/conftest.py b/tests/ahriman/core/conftest.py index 515b8c16..53cfcd1d 100644 --- a/tests/ahriman/core/conftest.py +++ b/tests/ahriman/core/conftest.py @@ -4,6 +4,7 @@ import pytest from ahriman.core.alpm.repo import Repo from ahriman.core.build_tools.task import Task from ahriman.core.configuration import Configuration +from ahriman.core.sign.gpg import GPG from ahriman.core.tree import Leaf from ahriman.models.package import Package from ahriman.models.repository_paths import RepositoryPaths @@ -63,6 +64,20 @@ def repo(configuration: Configuration, repository_paths: RepositoryPaths) -> Rep return Repo(configuration.get("repository", "name"), repository_paths, []) +@pytest.fixture +def gpg(configuration: Configuration) -> GPG: + """ + fixture for empty GPG + + Args: + configuration(Configuration): configuration fixture + + Returns: + GPG: GPG test instance + """ + return GPG(configuration) + + @pytest.fixture def task_ahriman(package_ahriman: Package, configuration: Configuration, repository_paths: RepositoryPaths) -> Task: """ diff --git a/tests/ahriman/core/report/test_html.py b/tests/ahriman/core/report/test_html.py index 1c4fc996..fa790b6c 100644 --- a/tests/ahriman/core/report/test_html.py +++ b/tests/ahriman/core/report/test_html.py @@ -16,4 +16,4 @@ def test_generate(configuration: Configuration, package_ahriman: Package, mocker report = HTML("x86_64", configuration, "html") report.generate([package_ahriman], Result()) - write_mock.assert_called_once_with(pytest.helpers.anyvar(int)) + write_mock.assert_called_once_with(pytest.helpers.anyvar(int), encoding="utf8") diff --git a/tests/ahriman/core/sign/conftest.py b/tests/ahriman/core/sign/conftest.py index 6351b062..e5bc6e7e 100644 --- a/tests/ahriman/core/sign/conftest.py +++ b/tests/ahriman/core/sign/conftest.py @@ -1,23 +1,8 @@ import pytest -from ahriman.core.configuration import Configuration from ahriman.core.sign.gpg import GPG -@pytest.fixture -def gpg(configuration: Configuration) -> GPG: - """ - fixture for empty GPG - - Args: - configuration(Configuration): configuration fixture - - Returns: - GPG: GPG test instance - """ - return GPG("x86_64", configuration) - - @pytest.fixture def gpg_with_key(gpg: GPG) -> GPG: """ diff --git a/tests/ahriman/core/sign/test_gpg.py b/tests/ahriman/core/sign/test_gpg.py index 9a36755a..e5427ffd 100644 --- a/tests/ahriman/core/sign/test_gpg.py +++ b/tests/ahriman/core/sign/test_gpg.py @@ -97,6 +97,33 @@ def test_key_download_failure(gpg: GPG, mocker: MockerFixture) -> None: gpg.key_download("keyserver.ubuntu.com", "0xE989490C") +def test_key_export(gpg: GPG, mocker: MockerFixture) -> None: + """ + must export gpg key correctly + """ + check_output_mock = mocker.patch("ahriman.core.sign.gpg.GPG._check_output", return_value="key") + assert gpg.key_export("k") == "key" + check_output_mock.assert_called_once_with("gpg", "--armor", "--no-emit-version", "--export", "k", + logger=pytest.helpers.anyvar(int)) + + +def test_key_fingerprint(gpg: GPG, mocker: MockerFixture) -> None: + """ + must extract fingerprint + """ + check_output_mock = mocker.patch( + "ahriman.core.sign.gpg.GPG._check_output", + return_value="""tru::1:1576103830:0:3:1:5 +fpr:::::::::C6EBB9222C3C8078631A0DE4BD2AC8C5E989490C: +sub:-:4096:1:7E3A4240CE3C45C2:1615121387::::::e::::::23: +fpr:::::::::43A663569A07EE1E4ECC55CC7E3A4240CE3C45C2:""") + + key = "0xCE3C45C2" + assert gpg.key_fingerprint(key) == "C6EBB9222C3C8078631A0DE4BD2AC8C5E989490C" + check_output_mock.assert_called_once_with("gpg", "--with-colons", "--fingerprint", key, + logger=pytest.helpers.anyvar(int)) + + def test_key_import(gpg: GPG, mocker: MockerFixture) -> None: """ must import PGP key from the server @@ -108,6 +135,21 @@ def test_key_import(gpg: GPG, mocker: MockerFixture) -> None: check_output_mock.assert_called_once_with("gpg", "--import", input_data="key", logger=pytest.helpers.anyvar(int)) +def test_keys(gpg: GPG) -> None: + """ + must extract keys + """ + assert gpg.keys() == [] + + gpg.default_key = "key" + assert gpg.keys() == [gpg.default_key] + + gpg.configuration.set_option("sign", "key_a", "key1") + gpg.configuration.set_option("sign", "key_b", "key1") + gpg.configuration.set_option("sign", "key_c", "key2") + assert gpg.keys() == ["key", "key1", "key2"] + + def test_process(gpg_with_key: GPG, mocker: MockerFixture) -> None: """ must call process method correctly diff --git a/tests/ahriman/core/support/pkgbuild/conftest.py b/tests/ahriman/core/support/pkgbuild/conftest.py index f7e87b07..af061f1d 100644 --- a/tests/ahriman/core/support/pkgbuild/conftest.py +++ b/tests/ahriman/core/support/pkgbuild/conftest.py @@ -1,8 +1,26 @@ import pytest +from ahriman.core.configuration import Configuration +from ahriman.core.sign.gpg import GPG +from ahriman.core.support.pkgbuild.keyring_generator import KeyringGenerator from ahriman.core.support.pkgbuild.pkgbuild_generator import PkgbuildGenerator +@pytest.fixture +def keyring_generator(gpg: GPG, configuration: Configuration) -> KeyringGenerator: + """ + fixture for keyring pkgbuild generator + + Args: + gpg(GPG): empty GPG fixture + configuration(Configuration): configuration fixture + + Returns: + KeyringGenerator: keyring generator test instance + """ + return KeyringGenerator(gpg, configuration, "keyring") + + @pytest.fixture def pkgbuild_generator() -> PkgbuildGenerator: """ diff --git a/tests/ahriman/core/support/pkgbuild/test_keyring_generator.py b/tests/ahriman/core/support/pkgbuild/test_keyring_generator.py new file mode 100644 index 00000000..5ae8b70d --- /dev/null +++ b/tests/ahriman/core/support/pkgbuild/test_keyring_generator.py @@ -0,0 +1,185 @@ +import pytest + +from pathlib import Path +from pytest_mock import MockerFixture +from unittest.mock import MagicMock, call as MockCall + +from ahriman.core.configuration import Configuration +from ahriman.core.exceptions import PkgbuildGeneratorError +from ahriman.core.sign.gpg import GPG +from ahriman.core.support.pkgbuild.keyring_generator import KeyringGenerator + + +def test_init_packagers(gpg: GPG, configuration: Configuration, mocker: MockerFixture) -> None: + """ + must extract packagers keys + """ + mocker.patch("ahriman.core.sign.gpg.GPG.keys", return_value=["key"]) + + assert KeyringGenerator(gpg, configuration, "keyring").packagers == ["key"] + + configuration.set_option("keyring", "packagers", "key1") + assert KeyringGenerator(gpg, configuration, "keyring").packagers == ["key1"] + + +def test_init_revoked(gpg: GPG, configuration: Configuration) -> None: + """ + must extract revoked keys + """ + assert KeyringGenerator(gpg, configuration, "keyring").revoked == [] + + configuration.set_option("keyring", "revoked", "key1") + assert KeyringGenerator(gpg, configuration, "keyring").revoked == ["key1"] + + +def test_init_trusted(gpg: GPG, configuration: Configuration) -> None: + """ + must extract trusted keys + """ + assert KeyringGenerator(gpg, configuration, "keyring").trusted == [] + + gpg.default_key = "key" + assert KeyringGenerator(gpg, configuration, "keyring").trusted == ["key"] + + configuration.set_option("keyring", "trusted", "key1") + assert KeyringGenerator(gpg, configuration, "keyring").trusted == ["key1"] + + +def test_license(gpg: GPG, configuration: Configuration) -> None: + """ + must generate correct licenses list + """ + assert KeyringGenerator(gpg, configuration, "keyring").license == ["Unlicense"] + + configuration.set_option("keyring", "license", "GPL MPL") + assert KeyringGenerator(gpg, configuration, "keyring").license == ["GPL", "MPL"] + + +def test_pkgdesc(gpg: GPG, configuration: Configuration) -> None: + """ + must generate correct pkgdesc property + """ + assert KeyringGenerator(gpg, configuration, "keyring").pkgdesc == "aur-clone PGP keyring" + + configuration.set_option("keyring", "description", "description") + assert KeyringGenerator(gpg, configuration, "keyring").pkgdesc == "description" + + +def test_pkgname(gpg: GPG, configuration: Configuration) -> None: + """ + must generate correct pkgname property + """ + assert KeyringGenerator(gpg, configuration, "keyring").pkgname == "aur-clone-keyring" + + configuration.set_option("keyring", "package", "keyring") + assert KeyringGenerator(gpg, configuration, "keyring").pkgname == "keyring" + + +def test_url(gpg: GPG, configuration: Configuration) -> None: + """ + must generate correct url property + """ + assert KeyringGenerator(gpg, configuration, "keyring").url == "" + + configuration.set_option("keyring", "homepage", "homepage") + assert KeyringGenerator(gpg, configuration, "keyring").url == "homepage" + + +def test_generate_gpg(keyring_generator: KeyringGenerator, mocker: MockerFixture) -> None: + """ + must correctly generate file with all PGP keys + """ + file_mock = MagicMock() + export_mock = mocker.patch("ahriman.core.sign.gpg.GPG.key_export", side_effect=lambda key: key) + open_mock = mocker.patch("pathlib.Path.open") + open_mock.return_value.__enter__.return_value = file_mock + keyring_generator.packagers = ["key"] + keyring_generator.revoked = ["revoked"] + keyring_generator.trusted = ["trusted", "key"] + + keyring_generator._generate_gpg(Path("local")) + open_mock.assert_called_once_with("w") + export_mock.assert_has_calls([MockCall("key"), MockCall("revoked"), MockCall("trusted")]) + file_mock.write.assert_has_calls([ + MockCall("key"), MockCall("\n"), + MockCall("revoked"), MockCall("\n"), + MockCall("trusted"), MockCall("\n"), + ]) + + +def test_generate_revoked(keyring_generator: KeyringGenerator, mocker: MockerFixture) -> None: + """ + must correctly generate file with revoked keys + """ + file_mock = MagicMock() + fingerprint_mock = mocker.patch("ahriman.core.sign.gpg.GPG.key_fingerprint", side_effect=lambda key: key) + open_mock = mocker.patch("pathlib.Path.open") + open_mock.return_value.__enter__.return_value = file_mock + keyring_generator.revoked = ["revoked"] + + keyring_generator._generate_revoked(Path("local")) + open_mock.assert_called_once_with("w") + fingerprint_mock.assert_called_once_with("revoked") + file_mock.write.assert_has_calls([MockCall("revoked"), MockCall("\n")]) + + +def test_generate_trusted(keyring_generator: KeyringGenerator, mocker: MockerFixture) -> None: + """ + must correctly generate file with trusted keys + """ + file_mock = MagicMock() + fingerprint_mock = mocker.patch("ahriman.core.sign.gpg.GPG.key_fingerprint", side_effect=lambda key: key) + open_mock = mocker.patch("pathlib.Path.open") + open_mock.return_value.__enter__.return_value = file_mock + keyring_generator.trusted = ["trusted", "trusted"] + + keyring_generator._generate_trusted(Path("local")) + open_mock.assert_called_once_with("w") + fingerprint_mock.assert_called_once_with("trusted") + file_mock.write.assert_has_calls([MockCall("trusted"), MockCall(":4:\n")]) + + +def test_generate_trusted_empty(keyring_generator: KeyringGenerator) -> None: + """ + must raise PkgbuildGeneratorError if no trusted keys set + """ + with pytest.raises(PkgbuildGeneratorError): + keyring_generator._generate_trusted(Path("local")) + + +def test_install(keyring_generator: KeyringGenerator) -> None: + """ + must return install functions + """ + assert keyring_generator.install() == """post_upgrade() { + if usr/bin/pacman-key -l >/dev/null 2>&1; then + usr/bin/pacman-key --populate aur-clone + usr/bin/pacman-key --updatedb + fi +} + +post_install() { + if [ -x usr/bin/pacman-key ]; then + post_upgrade + fi +}""" + + +def test_package(keyring_generator: KeyringGenerator) -> None: + """ + must generate package function correctly + """ + assert keyring_generator.package() == """{ + install -Dm644 "$srcdir/aur-clone.gpg" "$pkgdir/usr/share/pacman/keyrings/aur-clone.gpg" + install -Dm644 "$srcdir/aur-clone-revoked" "$pkgdir/usr/share/pacman/keyrings/aur-clone-revoked" + install -Dm644 "$srcdir/aur-clone-trusted" "$pkgdir/usr/share/pacman/keyrings/aur-clone-trusted" +}""" + + +def test_sources(keyring_generator: KeyringGenerator) -> None: + """ + must return valid sources files list + """ + assert keyring_generator.sources().get("aur-clone.gpg") + assert keyring_generator.sources().get("aur-clone-revoked") + assert keyring_generator.sources().get("aur-clone-trusted") diff --git a/tests/ahriman/core/support/pkgbuild/test_mirrorlist_generator.py b/tests/ahriman/core/support/pkgbuild/test_mirrorlist_generator.py index 59e764bd..6e5c3553 100644 --- a/tests/ahriman/core/support/pkgbuild/test_mirrorlist_generator.py +++ b/tests/ahriman/core/support/pkgbuild/test_mirrorlist_generator.py @@ -1,6 +1,4 @@ from pathlib import Path -from unittest.mock import MagicMock - from pytest_mock import MockerFixture from ahriman.core.configuration import Configuration @@ -61,14 +59,9 @@ def test_generate_mirrorlist(mirrorlist_generator: MirrorlistGenerator, mocker: """ must correctly generate mirrorlist file """ - path = Path("local") - file_mock = MagicMock() - open_mock = mocker.patch("pathlib.Path.open") - open_mock.return_value.__enter__.return_value = file_mock - - mirrorlist_generator._generate_mirrorlist(path) - open_mock.assert_called_once_with("w") - file_mock.writelines.assert_called_once_with(["Server = http://localhost\n"]) + write_mock = mocker.patch("pathlib.Path.write_text") + mirrorlist_generator._generate_mirrorlist(Path("local")) + write_mock.assert_called_once_with("Server = http://localhost\n", encoding="utf8") def test_package(mirrorlist_generator: MirrorlistGenerator) -> None: diff --git a/tests/ahriman/core/support/pkgbuild/test_pkgbuild_generator.py b/tests/ahriman/core/support/pkgbuild/test_pkgbuild_generator.py index 77e38a90..d4857c71 100644 --- a/tests/ahriman/core/support/pkgbuild/test_pkgbuild_generator.py +++ b/tests/ahriman/core/support/pkgbuild/test_pkgbuild_generator.py @@ -48,6 +48,13 @@ def test_url(pkgbuild_generator: PkgbuildGenerator) -> None: assert pkgbuild_generator.url == "" +def test_install(pkgbuild_generator: PkgbuildGenerator) -> None: + """ + must return empty install function + """ + assert pkgbuild_generator.install() is None + + def test_package(pkgbuild_generator: PkgbuildGenerator) -> None: """ must raise NotImplementedError on missing package function @@ -70,6 +77,25 @@ def test_sources(pkgbuild_generator: PkgbuildGenerator) -> None: assert pkgbuild_generator.sources() == {} +def test_write_install(pkgbuild_generator: PkgbuildGenerator, mocker: MockerFixture) -> None: + """ + must write install file + """ + mocker.patch.object(PkgbuildGenerator, "pkgname", "package") + mocker.patch("ahriman.core.support.pkgbuild.pkgbuild_generator.PkgbuildGenerator.install", return_value="content") + write_mock = mocker.patch("pathlib.Path.write_text") + + assert pkgbuild_generator.write_install(Path("local")) == [PkgbuildPatch("install", "package.install")] + write_mock.assert_called_once_with("content") + + +def test_write_install_empty(pkgbuild_generator: PkgbuildGenerator) -> None: + """ + must return empty patch list for missing install function + """ + assert pkgbuild_generator.write_install(Path("local")) == [] + + def test_write_pkgbuild(pkgbuild_generator: PkgbuildGenerator, mocker: MockerFixture) -> None: """ must write PKGBUILD content to file @@ -80,14 +106,17 @@ def test_write_pkgbuild(pkgbuild_generator: PkgbuildGenerator, mocker: MockerFix mocker.patch("ahriman.core.support.pkgbuild.pkgbuild_generator.PkgbuildGenerator.package", return_value="{}") patches_mock = mocker.patch("ahriman.core.support.pkgbuild.pkgbuild_generator.PkgbuildGenerator.patches", return_value=[PkgbuildPatch("property", "value")]) + install_mock = mocker.patch("ahriman.core.support.pkgbuild.pkgbuild_generator.PkgbuildGenerator.write_install", + return_value=[PkgbuildPatch("install", "pkgname.install")]) sources_mock = mocker.patch("ahriman.core.support.pkgbuild.pkgbuild_generator.PkgbuildGenerator.write_sources", return_value=[PkgbuildPatch("source", []), PkgbuildPatch("sha512sums", [])]) write_mock = mocker.patch("ahriman.models.pkgbuild_patch.PkgbuildPatch.write") pkgbuild_generator.write_pkgbuild(path) patches_mock.assert_called_once_with() + install_mock.assert_called_once_with(path) sources_mock.assert_called_once_with(path) - write_mock.assert_has_calls([MockCall(path / "PKGBUILD")] * 11) + write_mock.assert_has_calls([MockCall(path / "PKGBUILD")] * 12) def test_write_sources(pkgbuild_generator: PkgbuildGenerator, mocker: MockerFixture) -> None: diff --git a/tests/ahriman/core/support/test_keyring_trigger.py b/tests/ahriman/core/support/test_keyring_trigger.py new file mode 100644 index 00000000..8c82703f --- /dev/null +++ b/tests/ahriman/core/support/test_keyring_trigger.py @@ -0,0 +1,30 @@ +from pytest_mock import MockerFixture + +from ahriman.core.configuration import Configuration +from ahriman.core.sign.gpg import GPG +from ahriman.core.support import KeyringTrigger +from ahriman.models.context_key import ContextKey + + +def test_configuration_sections(configuration: Configuration) -> None: + """ + must correctly parse target list + """ + configuration.set_option("keyring", "target", "a b c") + assert KeyringTrigger.configuration_sections(configuration) == ["a", "b", "c"] + + configuration.remove_option("keyring", "target") + assert KeyringTrigger.configuration_sections(configuration) == [] + + +def test_on_start(configuration: Configuration, mocker: MockerFixture) -> None: + """ + must run report for specified targets + """ + gpg_mock = mocker.patch("ahriman.core._Context.get") + run_mock = mocker.patch("ahriman.core.support.package_creator.PackageCreator.run") + + trigger = KeyringTrigger("x86_64", configuration) + trigger.on_start() + gpg_mock.assert_called_once_with(ContextKey("sign", GPG)) + run_mock.assert_called_once_with() diff --git a/tests/ahriman/core/support/test_mirrorlist_trigger.py b/tests/ahriman/core/support/test_mirrorlist_trigger.py index 25abbcb6..e5e79013 100644 --- a/tests/ahriman/core/support/test_mirrorlist_trigger.py +++ b/tests/ahriman/core/support/test_mirrorlist_trigger.py @@ -19,7 +19,6 @@ def test_on_start(configuration: Configuration, mocker: MockerFixture) -> None: """ must run report for specified targets """ - configuration.set_option("mirrorlist", "target", "mirrorlist") run_mock = mocker.patch("ahriman.core.support.package_creator.PackageCreator.run") trigger = MirrorlistTrigger("x86_64", configuration) diff --git a/tests/testresources/core/ahriman.ini b/tests/testresources/core/ahriman.ini index ff7da9fb..ad35c8aa 100644 --- a/tests/testresources/core/ahriman.ini +++ b/tests/testresources/core/ahriman.ini @@ -25,7 +25,7 @@ ignore_packages = makechrootpkg_flags = makepkg_flags = --skippgpcheck triggers = ahriman.core.report.ReportTrigger ahriman.core.upload.UploadTrigger -triggers_known = ahriman.core.support.MirrorlistTrigger +triggers_known = ahriman.core.support.KeyringTrigger ahriman.core.support.MirrorlistTrigger [repository] name = aur-clone @@ -34,6 +34,9 @@ root = ../../../ [sign] target = +[keyring] +target = keyring + [mirrorlist] target = mirrorlist servers = http://localhost