diff --git a/package/archlinux/PKGBUILD b/package/archlinux/PKGBUILD index 0014ce0e..e7ef1892 100644 --- a/package/archlinux/PKGBUILD +++ b/package/archlinux/PKGBUILD @@ -23,7 +23,7 @@ optdepends=('aws-cli: sync to s3' source=("https://github.com/arcan1s/ahriman/releases/download/$pkgver/$pkgname-$pkgver-src.tar.xz" 'ahriman.sysusers' 'ahriman.tmpfiles') -sha512sums=('54286cfd1c9b03e7adfa639b976ace233e4e3ea8d2a2cbd11c22fc43eda60906e1d3b795e1505b40e41171948ba95d6591a4f7c328146200f4622a8ed657e8a5' +sha512sums=('d1a88fc3c5c14258cd0f84c815ebd254749ca8f9ba4dafe4a7385ac2eafc27cc552812a1951ccf1741ddc7ac7d4b2d2ebbe15960b0796e4f37653184adb86e30' '13718afec2c6786a18f0b223ef8e58dccf0688bca4cdbe203f14071f5031ed20120eb0ce38b52c76cfd6e8b6581a9c9eaa2743eb11abbaca637451a84c33f075' '55b20f6da3d66e7bbf2add5d95a3b60632df121717d25a993e56e737d14f51fe063eb6f1b38bd81cc32e05db01c0c1d80aaa720c45cde87f238d8b46cdb8cbc4') backup=('etc/ahriman.ini' diff --git a/package/etc/ahriman.ini.d/logging.ini b/package/etc/ahriman.ini.d/logging.ini index 476a39b3..42c58d0e 100644 --- a/package/etc/ahriman.ini.d/logging.ini +++ b/package/etc/ahriman.ini.d/logging.ini @@ -2,11 +2,17 @@ keys = root,builder,build_details,http [handlers] -keys = build_file_handler,file_handler,http_handler +keys = console_handler,build_file_handler,file_handler,http_handler [formatters] keys = generic_format +[handler_console_handler] +class = StreamHandler +level = DEBUG +formatter = generic_format +args = (sys.stderr,) + [handler_file_handler] class = logging.handlers.RotatingFileHandler level = DEBUG @@ -26,7 +32,7 @@ formatter = generic_format args = ('/var/log/ahriman/http.log', 'a', 20971520, 20) [formatter_generic_format] -format = %(asctime)s : %(levelname)s : %(funcName)s : %(message)s +format = [%(levelname)s %(asctime)s] [%(filename)s:%(lineno)d] [%(funcName)s]: %(message)s datefmt = [logger_root] diff --git a/src/ahriman/application/ahriman.py b/src/ahriman/application/ahriman.py index 7b5b3137..3ac71b5c 100644 --- a/src/ahriman/application/ahriman.py +++ b/src/ahriman/application/ahriman.py @@ -39,7 +39,7 @@ def _call(args: argparse.Namespace, architecture: str, config: Configuration) -> :return: True on success, False otherwise ''' try: - with Lock(args.lock, architecture, config): + with Lock(args, architecture, config): args.fn(args, architecture, config) return True except Exception: @@ -169,6 +169,8 @@ if __name__ == '__main__': parser.add_argument('-c', '--config', help='configuration path', default='/etc/ahriman.ini') parser.add_argument('--force', help='force run, remove file lock', action='store_true') parser.add_argument('--lock', help='lock file', default='/tmp/ahriman.lock') + parser.add_argument('--no-log', help='redirect all log messages to stderr', action='store_true') + parser.add_argument('--no-report', help='force disable reporting to web service', action='store_true') parser.add_argument('--unsafe', help='allow to run ahriman as non-ahriman user', action='store_true') parser.add_argument('-v', '--version', action='version', version=version.__version__) subparsers = parser.add_subparsers(title='command') @@ -178,7 +180,7 @@ if __name__ == '__main__': add_parser.add_argument('--without-dependencies', help='do not add dependencies', action='store_true') add_parser.set_defaults(fn=add) - check_parser = subparsers.add_parser('check', description='check for updates') + check_parser = subparsers.add_parser('check', description='check for updates. Same as update --dry-run --no-manual') check_parser.add_argument('package', help='filter check by packages', nargs='*') check_parser.add_argument('--no-vcs', help='do not check VCS packages', action='store_true') check_parser.set_defaults(fn=update, no_aur=False, no_manual=True, dry_run=True) @@ -216,20 +218,20 @@ if __name__ == '__main__': update_parser.add_argument('package', help='filter check by packages', nargs='*') update_parser.add_argument( '--dry-run', help='just perform check for updates, same as check command', action='store_true') - update_parser.add_argument('--no-aur', help='do not check for AUR updates', action='store_true') + update_parser.add_argument('--no-aur', help='do not check for AUR updates. Implies --no-vcs', action='store_true') update_parser.add_argument('--no-manual', help='do not include manual updates', action='store_true') update_parser.add_argument('--no-vcs', help='do not check VCS packages', action='store_true') update_parser.set_defaults(fn=update) web_parser = subparsers.add_parser('web', description='start web server') - web_parser.set_defaults(fn=web, lock=None) + web_parser.set_defaults(fn=web, lock=None, no_report=True) cmd_args = parser.parse_args() if 'fn' not in cmd_args: parser.print_help() sys.exit(1) - configuration = Configuration.from_path(cmd_args.config) + configuration = Configuration.from_path(cmd_args.config, not cmd_args.no_log) with Pool(len(cmd_args.architecture)) as pool: result = pool.starmap( _call, [(cmd_args, architecture, configuration) for architecture in cmd_args.architecture]) diff --git a/src/ahriman/application/application.py b/src/ahriman/application/application.py index a7a7dc61..182b3409 100644 --- a/src/ahriman/application/application.py +++ b/src/ahriman/application/application.py @@ -25,7 +25,7 @@ from typing import Callable, Iterable, List, Optional, Set from ahriman.core.build_tools.task import Task from ahriman.core.configuration import Configuration -from ahriman.core.repository import Repository +from ahriman.repository.repository import Repository from ahriman.core.tree import Tree from ahriman.models.package import Package diff --git a/src/ahriman/application/lock.py b/src/ahriman/application/lock.py index dd866e4f..61e1d294 100644 --- a/src/ahriman/application/lock.py +++ b/src/ahriman/application/lock.py @@ -53,7 +53,7 @@ class Lock: self.unsafe = args.unsafe self.root = config.get('repository', 'root') - self.reporter = Client.load(architecture, config) + self.reporter = Client() if args.no_report else Client.load(architecture, config) def __enter__(self) -> Lock: ''' diff --git a/src/ahriman/core/configuration.py b/src/ahriman/core/configuration.py index e1a71bda..2eb58d3b 100644 --- a/src/ahriman/core/configuration.py +++ b/src/ahriman/core/configuration.py @@ -37,7 +37,7 @@ class Configuration(configparser.RawConfigParser): :cvar STATIC_SECTIONS: known sections which are not architecture specific (required by dump) ''' - DEFAULT_LOG_FORMAT = '%(asctime)s : %(levelname)s : %(funcName)s : %(message)s' + DEFAULT_LOG_FORMAT = '[%(levelname)s %(asctime)s] [%(filename)s:%(lineno)d] [%(funcName)s]: %(message)s' DEFAULT_LOG_LEVEL = logging.DEBUG STATIC_SECTIONS = ['alpm', 'report', 'repository', 'settings', 'upload'] @@ -45,7 +45,7 @@ class Configuration(configparser.RawConfigParser): def __init__(self) -> None: ''' - default constructor + default constructor. In the most cases must not be called directly ''' configparser.RawConfigParser.__init__(self, allow_no_value=True) self.path: Optional[str] = None @@ -58,15 +58,16 @@ class Configuration(configparser.RawConfigParser): return self.get('settings', 'include') @classmethod - def from_path(cls: Type[Configuration], path: str) -> Configuration: + def from_path(cls: Type[Configuration], path: str, logfile: bool) -> Configuration: ''' constructor with full object initialization :param path: path to root configuration file + :param logfile: use log file to output messages :return: configuration instance ''' config = cls() config.load(path) - config.load_logging() + config.load_logging(logfile) return config def dump(self, architecture: str) -> Dict[str, Dict[str, str]]: @@ -129,13 +130,23 @@ class Configuration(configparser.RawConfigParser): except (FileNotFoundError, configparser.NoOptionError): pass - def load_logging(self) -> None: + def load_logging(self, logfile: bool) -> None: ''' setup logging settings from configuration + :param logfile: use log file to output messages ''' - try: - fileConfig(self.get('settings', 'logging')) - except PermissionError: + def file_logger() -> None: + try: + fileConfig(self.get('settings', 'logging')) + except PermissionError: + console_logger() + logging.error('could not create logfile, fallback to stderr', exc_info=True) + + def console_logger() -> None: logging.basicConfig(filename=None, format=Configuration.DEFAULT_LOG_FORMAT, level=Configuration.DEFAULT_LOG_LEVEL) - logging.error('could not create logfile, fallback to stderr', exc_info=True) + + if logfile: + file_logger() + else: + console_logger() diff --git a/src/ahriman/core/repository.py b/src/ahriman/core/repository.py deleted file mode 100644 index 35cd1522..00000000 --- a/src/ahriman/core/repository.py +++ /dev/null @@ -1,297 +0,0 @@ -# -# Copyright (c) 2021 Evgenii Alekseev. -# -# 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 logging -import os -import shutil - -from typing import Dict, Iterable, List, Optional - -from ahriman.core.alpm.pacman import Pacman -from ahriman.core.alpm.repo import Repo -from ahriman.core.build_tools.task import Task -from ahriman.core.configuration import Configuration -from ahriman.core.report.report import Report -from ahriman.core.sign.gpg import GPG -from ahriman.core.upload.uploader import Uploader -from ahriman.core.util import package_like -from ahriman.core.watcher.client import Client -from ahriman.models.package import Package -from ahriman.models.repository_paths import RepositoryPaths - - -class Repository: - ''' - base repository control class - :ivar architecture: repository architecture - :ivar aur_url: base AUR url - :ivar config: configuration instance - :ivar logger: class logger - :ivar name: repository name - :ivar pacman: alpm wrapper instance - :ivar paths: repository paths instance - :ivar repo: repo commands wrapper instance - :ivar reporter: build status reporter instance - :ivar sign: GPG wrapper instance - ''' - - def __init__(self, architecture: str, config: Configuration) -> None: - ''' - default constructor - :param architecture: repository architecture - :param config: configuration instance - ''' - self.logger = logging.getLogger('builder') - self.architecture = architecture - self.config = config - - self.aur_url = config.get('alpm', 'aur_url') - self.name = config.get('repository', 'name') - - self.paths = RepositoryPaths(config.get('repository', 'root'), architecture) - self.paths.create_tree() - - self.pacman = Pacman(config) - self.sign = GPG(architecture, config) - self.repo = Repo(self.name, self.paths, self.sign.repository_sign_args) - self.reporter = Client.load(architecture, config) - - def clear_build(self) -> None: - ''' - clear sources directory - ''' - for package in os.listdir(self.paths.sources): - shutil.rmtree(os.path.join(self.paths.sources, package)) - - def clear_cache(self) -> None: - ''' - clear cache directory - ''' - for package in os.listdir(self.paths.cache): - shutil.rmtree(os.path.join(self.paths.cache, package)) - - def clear_chroot(self) -> None: - ''' - clear cache directory. Warning: this method is architecture independent and will clear every chroot - ''' - for chroot in os.listdir(self.paths.chroot): - shutil.rmtree(os.path.join(self.paths.chroot, chroot)) - - def clear_manual(self) -> None: - ''' - clear directory with manual package updates - ''' - for package in os.listdir(self.paths.manual): - shutil.rmtree(os.path.join(self.paths.manual, package)) - - def clear_packages(self) -> None: - ''' - clear directory with built packages (NOT repository itself) - ''' - for package in self.packages_built(): - os.remove(package) - - def packages(self) -> List[Package]: - ''' - generate list of repository packages - :return: list of packages properties - ''' - result: Dict[str, Package] = {} - for fn in os.listdir(self.paths.repository): - if not package_like(fn): - continue - full_path = os.path.join(self.paths.repository, fn) - try: - local = Package.load(full_path, self.pacman, self.aur_url) - result.setdefault(local.base, local).packages.update(local.packages) - except Exception: - self.logger.exception(f'could not load package from {fn}', exc_info=True) - continue - return list(result.values()) - - def packages_built(self) -> List[str]: - ''' - get list of files in built packages directory - :return: list of filenames from the directory - ''' - return [ - os.path.join(self.paths.packages, fn) - for fn in os.listdir(self.paths.packages) - ] - - def process_build(self, updates: Iterable[Package]) -> List[str]: - ''' - build packages - :param updates: list of packages properties to build - :return: `packages_built` - ''' - def build_single(package: Package) -> None: - self.reporter.set_building(package.base) - task = Task(package, self.architecture, self.config, self.paths) - task.init() - built = task.build() - for src in built: - dst = os.path.join(self.paths.packages, os.path.basename(src)) - shutil.move(src, dst) - - for package in updates: - try: - build_single(package) - except Exception: - self.reporter.set_failed(package.base) - self.logger.exception(f'{package.base} ({self.architecture}) build exception', exc_info=True) - continue - self.clear_build() - - return self.packages_built() - - def process_remove(self, packages: Iterable[str]) -> str: - ''' - remove packages from list - :param packages: list of package names or bases to rmeove - :return: path to repository database - ''' - def remove_single(package: str) -> None: - try: - self.repo.remove(package) - except Exception: - self.logger.exception(f'could not remove {package}', exc_info=True) - - requested = set(packages) - for local in self.packages(): - if local.base in packages: - to_remove = set(local.packages.keys()) - self.reporter.remove(local.base) # we only update status page in case of base removal - elif requested.intersection(local.packages.keys()): - to_remove = requested.intersection(local.packages.keys()) - else: - to_remove = set() - for package in to_remove: - remove_single(package) - - return self.repo.repo_path - - def process_report(self, targets: Optional[Iterable[str]]) -> None: - ''' - generate reports - :param targets: list of targets to generate reports. Configuration option will be used if it is not set - ''' - if targets is None: - targets = self.config.getlist('report', 'target') - for target in targets: - Report.run(self.architecture, self.config, target, self.packages()) - - def process_sync(self, targets: Optional[Iterable[str]]) -> None: - ''' - process synchronization to remote servers - :param targets: list of targets to sync. Configuration option will be used if it is not set - ''' - if targets is None: - targets = self.config.getlist('upload', 'target') - for target in targets: - Uploader.run(self.architecture, self.config, target, self.paths.repository) - - def process_update(self, packages: Iterable[str]) -> str: - ''' - sign packages, add them to repository and update repository database - :param packages: list of filenames to run - :return: path to repository database - ''' - def update_single(fn: Optional[str], base: str) -> None: - if fn is None: - self.logger.warning(f'received empty package name for base {base}') - return # suppress type checking, it never can be none actually - files = self.sign.sign_package(fn, base) - for src in files: - dst = os.path.join(self.paths.repository, os.path.basename(src)) - shutil.move(src, dst) - package_fn = os.path.join(self.paths.repository, os.path.basename(fn)) - self.repo.add(package_fn) - - # we are iterating over bases, not single packages - updates: Dict[str, Package] = {} - for fn in packages: - local = Package.load(fn, self.pacman, self.aur_url) - updates.setdefault(local.base, local).packages.update(local.packages) - - for local in updates.values(): - try: - for description in local.packages.values(): - update_single(description.filename, local.base) - self.reporter.set_success(local) - except Exception: - self.reporter.set_failed(local.base) - self.logger.exception(f'could not process {local.base}', exc_info=True) - self.clear_packages() - - return self.repo.repo_path - - def updates_aur(self, filter_packages: Iterable[str], no_vcs: bool) -> List[Package]: - ''' - check AUR for updates - :param filter_packages: do not check every package just specified in the list - :param no_vcs: do not check VCS packages - :return: list of packages which are out-of-dated - ''' - result: List[Package] = [] - - build_section = self.config.get_section_name('build', self.architecture) - ignore_list = self.config.getlist(build_section, 'ignore_packages') - - for local in self.packages(): - if local.base in ignore_list: - continue - if local.is_vcs and no_vcs: - continue - if filter_packages and local.base not in filter_packages: - continue - - try: - remote = Package.load(local.base, self.pacman, self.aur_url) - if local.is_outdated(remote, self.paths): - self.reporter.set_pending(local.base) - result.append(remote) - except Exception: - self.reporter.set_failed(local.base) - self.logger.exception(f'could not load remote package {local.base}', exc_info=True) - continue - - return result - - def updates_manual(self) -> List[Package]: - ''' - check for packages for which manual update has been requested - :return: list of packages which are out-of-dated - ''' - result: List[Package] = [] - known_bases = {package.base for package in self.packages()} - - for fn in os.listdir(self.paths.manual): - try: - local = Package.load(os.path.join(self.paths.manual, fn), self.pacman, self.aur_url) - result.append(local) - if local.base not in known_bases: - self.reporter.set_unknown(local) - else: - self.reporter.set_pending(local.base) - except Exception: - self.logger.exception(f'could not add package from {fn}', exc_info=True) - self.clear_manual() - - return result diff --git a/src/ahriman/core/watcher/watcher.py b/src/ahriman/core/watcher/watcher.py index acabb719..456db20a 100644 --- a/src/ahriman/core/watcher/watcher.py +++ b/src/ahriman/core/watcher/watcher.py @@ -24,7 +24,7 @@ import os from typing import Any, Dict, List, Optional, Tuple from ahriman.core.configuration import Configuration -from ahriman.core.repository import Repository +from ahriman.repository.repository import Repository from ahriman.models.build_status import BuildStatus, BuildStatusEnum from ahriman.models.package import Package @@ -58,7 +58,7 @@ class Watcher: ''' :return: path to dump with json cache ''' - return os.path.join(self.repository.paths.root, 'cache.json') + return os.path.join(self.repository.paths.root, 'status_cache.json') @property def packages(self) -> List[Tuple[Package, BuildStatus]]: @@ -99,8 +99,11 @@ class Watcher: } for package, status in self.packages ] } - with open(self.cache_path, 'w') as cache: - json.dump(dump, cache) + try: + with open(self.cache_path, 'w') as cache: + json.dump(dump, cache) + except Exception: + self.logger.exception('cannot dump cache', exc_info=True) def get(self, base: str) -> Tuple[Package, BuildStatus]: ''' diff --git a/src/ahriman/core/watcher/web_client.py b/src/ahriman/core/watcher/web_client.py index 55cd3d02..a0bc210d 100644 --- a/src/ahriman/core/watcher/web_client.py +++ b/src/ahriman/core/watcher/web_client.py @@ -20,8 +20,6 @@ import logging import requests -from dataclasses import asdict - from ahriman.core.watcher.client import Client from ahriman.models.build_status import BuildStatusEnum from ahriman.models.package import Package @@ -68,12 +66,14 @@ class WebClient(Client): ''' payload = { 'status': status.value, - 'package': asdict(package) + 'package': package.view() } try: response = requests.post(self._package_url(package.base), json=payload) response.raise_for_status() + except requests.exceptions.HTTPError as e: + self.logger.exception(f'could not add {package.base}: {e.response.text}', exc_info=True) except Exception: self.logger.exception(f'could not add {package.base}', exc_info=True) @@ -85,6 +85,8 @@ class WebClient(Client): try: response = requests.delete(self._package_url(base)) response.raise_for_status() + except requests.exceptions.HTTPError as e: + self.logger.exception(f'could not delete {base}: {e.response.text}', exc_info=True) except Exception: self.logger.exception(f'could not delete {base}', exc_info=True) @@ -99,6 +101,8 @@ class WebClient(Client): try: response = requests.post(self._package_url(base), json=payload) response.raise_for_status() + except requests.exceptions.HTTPError as e: + self.logger.exception(f'could not update {base}: {e.response.text}', exc_info=True) except Exception: self.logger.exception(f'could not update {base}', exc_info=True) @@ -112,5 +116,7 @@ class WebClient(Client): try: response = requests.post(self._ahriman_url(), json=payload) response.raise_for_status() + except requests.exceptions.HTTPError as e: + self.logger.exception(f'could not update service status: {e.response.text}', exc_info=True) except Exception: self.logger.exception('could not update service status', exc_info=True) diff --git a/src/ahriman/models/package.py b/src/ahriman/models/package.py index 2f66dea4..4ccadfd6 100644 --- a/src/ahriman/models/package.py +++ b/src/ahriman/models/package.py @@ -126,7 +126,7 @@ class Package: ''' packages = { key: PackageDescription(**value) - for key, value in dump.get('packages', {}) + for key, value in dump.get('packages', {}).items() } return Package( base=dump['base'], diff --git a/src/ahriman/repository/__init__.py b/src/ahriman/repository/__init__.py new file mode 100644 index 00000000..b7917f9a --- /dev/null +++ b/src/ahriman/repository/__init__.py @@ -0,0 +1,19 @@ +# +# Copyright (c) 2021 Evgenii Alekseev. +# +# 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/repository/cleaner.py b/src/ahriman/repository/cleaner.py new file mode 100644 index 00000000..4e9c5185 --- /dev/null +++ b/src/ahriman/repository/cleaner.py @@ -0,0 +1,78 @@ +# +# Copyright (c) 2021 Evgenii Alekseev. +# +# 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 os +import shutil + +from typing import List + +from ahriman.repository.properties import Properties + + +class Cleaner(Properties): + ''' + trait to clean common repository objects + ''' + + def packages_built(self) -> List[str]: + ''' + get list of files in built packages directory + :return: list of filenames from the directory + ''' + raise NotImplementedError + + def clear_build(self) -> None: + ''' + clear sources directory + ''' + self.logger.info('clear package sources directory') + for package in os.listdir(self.paths.sources): + shutil.rmtree(os.path.join(self.paths.sources, package)) + + def clear_cache(self) -> None: + ''' + clear cache directory + ''' + self.logger.info('clear packages sources cache directory') + for package in os.listdir(self.paths.cache): + shutil.rmtree(os.path.join(self.paths.cache, package)) + + def clear_chroot(self) -> None: + ''' + clear cache directory. Warning: this method is architecture independent and will clear every chroot + ''' + self.logger.info('clear build chroot directory') + for chroot in os.listdir(self.paths.chroot): + shutil.rmtree(os.path.join(self.paths.chroot, chroot)) + + def clear_manual(self) -> None: + ''' + clear directory with manual package updates + ''' + self.logger.info('clear manual packages') + for package in os.listdir(self.paths.manual): + shutil.rmtree(os.path.join(self.paths.manual, package)) + + def clear_packages(self) -> None: + ''' + clear directory with built packages (NOT repository itself) + ''' + self.logger.info('clear built packages directory') + for package in self.packages_built(): + os.remove(package) diff --git a/src/ahriman/repository/executor.py b/src/ahriman/repository/executor.py new file mode 100644 index 00000000..0b4da52b --- /dev/null +++ b/src/ahriman/repository/executor.py @@ -0,0 +1,151 @@ +# +# Copyright (c) 2021 Evgenii Alekseev. +# +# 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 os +import shutil + +from typing import Dict, Iterable, List, Optional + +from ahriman.core.build_tools.task import Task +from ahriman.core.report.report import Report +from ahriman.core.upload.uploader import Uploader +from ahriman.models.package import Package +from ahriman.repository.cleaner import Cleaner + + +class Executor(Cleaner): + ''' + trait for common repository update processes + ''' + + def packages(self) -> List[Package]: + ''' + generate list of repository packages + :return: list of packages properties + ''' + raise NotImplementedError + + def process_build(self, updates: Iterable[Package]) -> List[str]: + ''' + build packages + :param updates: list of packages properties to build + :return: `packages_built` + ''' + def build_single(package: Package) -> None: + self.reporter.set_building(package.base) + task = Task(package, self.architecture, self.config, self.paths) + task.init() + built = task.build() + for src in built: + dst = os.path.join(self.paths.packages, os.path.basename(src)) + shutil.move(src, dst) + + for package in updates: + try: + build_single(package) + except Exception: + self.reporter.set_failed(package.base) + self.logger.exception(f'{package.base} ({self.architecture}) build exception', exc_info=True) + continue + self.clear_build() + + return self.packages_built() + + def process_remove(self, packages: Iterable[str]) -> str: + ''' + remove packages from list + :param packages: list of package names or bases to rmeove + :return: path to repository database + ''' + def remove_single(package: str) -> None: + try: + self.repo.remove(package) + except Exception: + self.logger.exception(f'could not remove {package}', exc_info=True) + + requested = set(packages) + for local in self.packages(): + if local.base in packages: + to_remove = set(local.packages.keys()) + self.reporter.remove(local.base) # we only update status page in case of base removal + elif requested.intersection(local.packages.keys()): + to_remove = requested.intersection(local.packages.keys()) + else: + to_remove = set() + for package in to_remove: + remove_single(package) + + return self.repo.repo_path + + def process_report(self, targets: Optional[Iterable[str]]) -> None: + ''' + generate reports + :param targets: list of targets to generate reports. Configuration option will be used if it is not set + ''' + if targets is None: + targets = self.config.getlist('report', 'target') + for target in targets: + Report.run(self.architecture, self.config, target, self.packages()) + + def process_sync(self, targets: Optional[Iterable[str]]) -> None: + ''' + process synchronization to remote servers + :param targets: list of targets to sync. Configuration option will be used if it is not set + ''' + if targets is None: + targets = self.config.getlist('upload', 'target') + for target in targets: + Uploader.run(self.architecture, self.config, target, self.paths.repository) + + def process_update(self, packages: Iterable[str]) -> str: + ''' + sign packages, add them to repository and update repository database + :param packages: list of filenames to run + :return: path to repository database + ''' + def update_single(fn: Optional[str], base: str) -> None: + if fn is None: + self.logger.warning(f'received empty package name for base {base}') + return # suppress type checking, it never can be none actually + # in theory it might be NOT packages directory, but we suppose it is + full_path = os.path.join(self.paths.packages, fn) + files = self.sign.sign_package(full_path, base) + for src in files: + dst = os.path.join(self.paths.repository, os.path.basename(src)) + shutil.move(src, dst) + package_path = os.path.join(self.paths.repository, fn) + self.repo.add(package_path) + + # we are iterating over bases, not single packages + updates: Dict[str, Package] = {} + for fn in packages: + local = Package.load(fn, self.pacman, self.aur_url) + updates.setdefault(local.base, local).packages.update(local.packages) + + for local in updates.values(): + try: + for description in local.packages.values(): + update_single(description.filename, local.base) + self.reporter.set_success(local) + except Exception: + self.reporter.set_failed(local.base) + self.logger.exception(f'could not process {local.base}', exc_info=True) + self.clear_packages() + + return self.repo.repo_path diff --git a/src/ahriman/repository/properties.py b/src/ahriman/repository/properties.py new file mode 100644 index 00000000..9e991006 --- /dev/null +++ b/src/ahriman/repository/properties.py @@ -0,0 +1,59 @@ +# +# Copyright (c) 2021 Evgenii Alekseev. +# +# 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 logging + +from ahriman.core.alpm.pacman import Pacman +from ahriman.core.alpm.repo import Repo +from ahriman.core.configuration import Configuration +from ahriman.core.sign.gpg import GPG +from ahriman.core.watcher.client import Client +from ahriman.models.repository_paths import RepositoryPaths + + +class Properties: + ''' + repository internal objects holder + :ivar architecture: repository architecture + :ivar aur_url: base AUR url + :ivar config: configuration instance + :ivar logger: class logger + :ivar name: repository name + :ivar pacman: alpm wrapper instance + :ivar paths: repository paths instance + :ivar repo: repo commands wrapper instance + :ivar reporter: build status reporter instance + :ivar sign: GPG wrapper instance + ''' + + def __init__(self, architecture: str, config: Configuration) -> None: + self.logger = logging.getLogger('builder') + self.architecture = architecture + self.config = config + + self.aur_url = config.get('alpm', 'aur_url') + self.name = config.get('repository', 'name') + + self.paths = RepositoryPaths(config.get('repository', 'root'), architecture) + self.paths.create_tree() + + self.pacman = Pacman(config) + self.sign = GPG(architecture, config) + self.repo = Repo(self.name, self.paths, self.sign.repository_sign_args) + self.reporter = Client.load(architecture, config) diff --git a/src/ahriman/repository/repository.py b/src/ahriman/repository/repository.py new file mode 100644 index 00000000..24fb06be --- /dev/null +++ b/src/ahriman/repository/repository.py @@ -0,0 +1,61 @@ +# +# Copyright (c) 2021 Evgenii Alekseev. +# +# 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 os + +from typing import Dict, List + +from ahriman.core.util import package_like +from ahriman.models.package import Package +from ahriman.repository.executor import Executor +from ahriman.repository.update_handler import UpdateHandler + + +class Repository(Executor, UpdateHandler): + ''' + base repository control class + ''' + + def packages(self) -> List[Package]: + ''' + generate list of repository packages + :return: list of packages properties + ''' + result: Dict[str, Package] = {} + for fn in os.listdir(self.paths.repository): + if not package_like(fn): + continue + full_path = os.path.join(self.paths.repository, fn) + try: + local = Package.load(full_path, self.pacman, self.aur_url) + result.setdefault(local.base, local).packages.update(local.packages) + except Exception: + self.logger.exception(f'could not load package from {fn}', exc_info=True) + continue + return list(result.values()) + + def packages_built(self) -> List[str]: + ''' + get list of files in built packages directory + :return: list of filenames from the directory + ''' + return [ + os.path.join(self.paths.packages, fn) + for fn in os.listdir(self.paths.packages) + ] diff --git a/src/ahriman/repository/update_handler.py b/src/ahriman/repository/update_handler.py new file mode 100644 index 00000000..94d88a77 --- /dev/null +++ b/src/ahriman/repository/update_handler.py @@ -0,0 +1,92 @@ +# +# Copyright (c) 2021 Evgenii Alekseev. +# +# 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 os + +from typing import Iterable, List + +from ahriman.models.package import Package +from ahriman.repository.cleaner import Cleaner + + +class UpdateHandler(Cleaner): + ''' + trait to get package update list + ''' + + def packages(self) -> List[Package]: + ''' + generate list of repository packages + :return: list of packages properties + ''' + raise NotImplementedError + + def updates_aur(self, filter_packages: Iterable[str], no_vcs: bool) -> List[Package]: + ''' + check AUR for updates + :param filter_packages: do not check every package just specified in the list + :param no_vcs: do not check VCS packages + :return: list of packages which are out-of-dated + ''' + result: List[Package] = [] + + build_section = self.config.get_section_name('build', self.architecture) + ignore_list = self.config.getlist(build_section, 'ignore_packages') + + for local in self.packages(): + if local.base in ignore_list: + continue + if local.is_vcs and no_vcs: + continue + if filter_packages and local.base not in filter_packages: + continue + + try: + remote = Package.load(local.base, self.pacman, self.aur_url) + if local.is_outdated(remote, self.paths): + self.reporter.set_pending(local.base) + result.append(remote) + except Exception: + self.reporter.set_failed(local.base) + self.logger.exception(f'could not load remote package {local.base}', exc_info=True) + continue + + return result + + def updates_manual(self) -> List[Package]: + ''' + check for packages for which manual update has been requested + :return: list of packages which are out-of-dated + ''' + result: List[Package] = [] + known_bases = {package.base for package in self.packages()} + + for fn in os.listdir(self.paths.manual): + try: + local = Package.load(os.path.join(self.paths.manual, fn), self.pacman, self.aur_url) + result.append(local) + if local.base not in known_bases: + self.reporter.set_unknown(local) + else: + self.reporter.set_pending(local.base) + except Exception: + self.logger.exception(f'could not add package from {fn}', exc_info=True) + self.clear_manual() + + return result