web service improvements

* load and save web service state to cache file
* disable web reporting to self
* restore console handler settings
* allow to redirect logs to stderr
* verbose http error logging
* update package status by group, not by single package
* split Repository class to several traits
* move json generators/readers to dataclasses
This commit is contained in:
Evgenii Alekseev 2021-03-20 18:01:57 +03:00
parent 3e2fb7b4e6
commit 413d3b7509
16 changed files with 515 additions and 324 deletions

View File

@ -23,7 +23,7 @@ optdepends=('aws-cli: sync to s3'
source=("https://github.com/arcan1s/ahriman/releases/download/$pkgver/$pkgname-$pkgver-src.tar.xz" source=("https://github.com/arcan1s/ahriman/releases/download/$pkgver/$pkgname-$pkgver-src.tar.xz"
'ahriman.sysusers' 'ahriman.sysusers'
'ahriman.tmpfiles') 'ahriman.tmpfiles')
sha512sums=('54286cfd1c9b03e7adfa639b976ace233e4e3ea8d2a2cbd11c22fc43eda60906e1d3b795e1505b40e41171948ba95d6591a4f7c328146200f4622a8ed657e8a5' sha512sums=('d1a88fc3c5c14258cd0f84c815ebd254749ca8f9ba4dafe4a7385ac2eafc27cc552812a1951ccf1741ddc7ac7d4b2d2ebbe15960b0796e4f37653184adb86e30'
'13718afec2c6786a18f0b223ef8e58dccf0688bca4cdbe203f14071f5031ed20120eb0ce38b52c76cfd6e8b6581a9c9eaa2743eb11abbaca637451a84c33f075' '13718afec2c6786a18f0b223ef8e58dccf0688bca4cdbe203f14071f5031ed20120eb0ce38b52c76cfd6e8b6581a9c9eaa2743eb11abbaca637451a84c33f075'
'55b20f6da3d66e7bbf2add5d95a3b60632df121717d25a993e56e737d14f51fe063eb6f1b38bd81cc32e05db01c0c1d80aaa720c45cde87f238d8b46cdb8cbc4') '55b20f6da3d66e7bbf2add5d95a3b60632df121717d25a993e56e737d14f51fe063eb6f1b38bd81cc32e05db01c0c1d80aaa720c45cde87f238d8b46cdb8cbc4')
backup=('etc/ahriman.ini' backup=('etc/ahriman.ini'

View File

@ -2,11 +2,17 @@
keys = root,builder,build_details,http keys = root,builder,build_details,http
[handlers] [handlers]
keys = build_file_handler,file_handler,http_handler keys = console_handler,build_file_handler,file_handler,http_handler
[formatters] [formatters]
keys = generic_format keys = generic_format
[handler_console_handler]
class = StreamHandler
level = DEBUG
formatter = generic_format
args = (sys.stderr,)
[handler_file_handler] [handler_file_handler]
class = logging.handlers.RotatingFileHandler class = logging.handlers.RotatingFileHandler
level = DEBUG level = DEBUG
@ -26,7 +32,7 @@ formatter = generic_format
args = ('/var/log/ahriman/http.log', 'a', 20971520, 20) args = ('/var/log/ahriman/http.log', 'a', 20971520, 20)
[formatter_generic_format] [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 = datefmt =
[logger_root] [logger_root]

View File

@ -39,7 +39,7 @@ def _call(args: argparse.Namespace, architecture: str, config: Configuration) ->
:return: True on success, False otherwise :return: True on success, False otherwise
''' '''
try: try:
with Lock(args.lock, architecture, config): with Lock(args, architecture, config):
args.fn(args, architecture, config) args.fn(args, architecture, config)
return True return True
except Exception: except Exception:
@ -169,6 +169,8 @@ if __name__ == '__main__':
parser.add_argument('-c', '--config', help='configuration path', default='/etc/ahriman.ini') 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('--force', help='force run, remove file lock', action='store_true')
parser.add_argument('--lock', help='lock file', default='/tmp/ahriman.lock') 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('--unsafe', help='allow to run ahriman as non-ahriman user', action='store_true')
parser.add_argument('-v', '--version', action='version', version=version.__version__) parser.add_argument('-v', '--version', action='version', version=version.__version__)
subparsers = parser.add_subparsers(title='command') 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.add_argument('--without-dependencies', help='do not add dependencies', action='store_true')
add_parser.set_defaults(fn=add) 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('package', help='filter check by packages', nargs='*')
check_parser.add_argument('--no-vcs', help='do not check VCS packages', action='store_true') 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) 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('package', help='filter check by packages', nargs='*')
update_parser.add_argument( update_parser.add_argument(
'--dry-run', help='just perform check for updates, same as check command', action='store_true') '--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-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.add_argument('--no-vcs', help='do not check VCS packages', action='store_true')
update_parser.set_defaults(fn=update) update_parser.set_defaults(fn=update)
web_parser = subparsers.add_parser('web', description='start web server') 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() cmd_args = parser.parse_args()
if 'fn' not in cmd_args: if 'fn' not in cmd_args:
parser.print_help() parser.print_help()
sys.exit(1) 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: with Pool(len(cmd_args.architecture)) as pool:
result = pool.starmap( result = pool.starmap(
_call, [(cmd_args, architecture, configuration) for architecture in cmd_args.architecture]) _call, [(cmd_args, architecture, configuration) for architecture in cmd_args.architecture])

View File

@ -25,7 +25,7 @@ from typing import Callable, Iterable, List, Optional, Set
from ahriman.core.build_tools.task import Task from ahriman.core.build_tools.task import Task
from ahriman.core.configuration import Configuration 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.core.tree import Tree
from ahriman.models.package import Package from ahriman.models.package import Package

View File

@ -53,7 +53,7 @@ class Lock:
self.unsafe = args.unsafe self.unsafe = args.unsafe
self.root = config.get('repository', 'root') 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: def __enter__(self) -> Lock:
''' '''

View File

@ -37,7 +37,7 @@ class Configuration(configparser.RawConfigParser):
:cvar STATIC_SECTIONS: known sections which are not architecture specific (required by dump) :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 DEFAULT_LOG_LEVEL = logging.DEBUG
STATIC_SECTIONS = ['alpm', 'report', 'repository', 'settings', 'upload'] STATIC_SECTIONS = ['alpm', 'report', 'repository', 'settings', 'upload']
@ -45,7 +45,7 @@ class Configuration(configparser.RawConfigParser):
def __init__(self) -> None: 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) configparser.RawConfigParser.__init__(self, allow_no_value=True)
self.path: Optional[str] = None self.path: Optional[str] = None
@ -58,15 +58,16 @@ class Configuration(configparser.RawConfigParser):
return self.get('settings', 'include') return self.get('settings', 'include')
@classmethod @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 constructor with full object initialization
:param path: path to root configuration file :param path: path to root configuration file
:param logfile: use log file to output messages
:return: configuration instance :return: configuration instance
''' '''
config = cls() config = cls()
config.load(path) config.load(path)
config.load_logging() config.load_logging(logfile)
return config return config
def dump(self, architecture: str) -> Dict[str, Dict[str, str]]: def dump(self, architecture: str) -> Dict[str, Dict[str, str]]:
@ -129,13 +130,23 @@ class Configuration(configparser.RawConfigParser):
except (FileNotFoundError, configparser.NoOptionError): except (FileNotFoundError, configparser.NoOptionError):
pass pass
def load_logging(self) -> None: def load_logging(self, logfile: bool) -> None:
''' '''
setup logging settings from configuration setup logging settings from configuration
:param logfile: use log file to output messages
''' '''
try: def file_logger() -> None:
fileConfig(self.get('settings', 'logging')) try:
except PermissionError: 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, logging.basicConfig(filename=None, format=Configuration.DEFAULT_LOG_FORMAT,
level=Configuration.DEFAULT_LOG_LEVEL) level=Configuration.DEFAULT_LOG_LEVEL)
logging.error('could not create logfile, fallback to stderr', exc_info=True)
if logfile:
file_logger()
else:
console_logger()

View File

@ -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 <http://www.gnu.org/licenses/>.
#
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

View File

@ -24,7 +24,7 @@ import os
from typing import Any, Dict, List, Optional, Tuple from typing import Any, Dict, List, Optional, Tuple
from ahriman.core.configuration import Configuration 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.build_status import BuildStatus, BuildStatusEnum
from ahriman.models.package import Package from ahriman.models.package import Package
@ -58,7 +58,7 @@ class Watcher:
''' '''
:return: path to dump with json cache :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 @property
def packages(self) -> List[Tuple[Package, BuildStatus]]: def packages(self) -> List[Tuple[Package, BuildStatus]]:
@ -99,8 +99,11 @@ class Watcher:
} for package, status in self.packages } for package, status in self.packages
] ]
} }
with open(self.cache_path, 'w') as cache: try:
json.dump(dump, cache) 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]: def get(self, base: str) -> Tuple[Package, BuildStatus]:
''' '''

View File

@ -20,8 +20,6 @@
import logging import logging
import requests import requests
from dataclasses import asdict
from ahriman.core.watcher.client import Client from ahriman.core.watcher.client import Client
from ahriman.models.build_status import BuildStatusEnum from ahriman.models.build_status import BuildStatusEnum
from ahriman.models.package import Package from ahriman.models.package import Package
@ -68,12 +66,14 @@ class WebClient(Client):
''' '''
payload = { payload = {
'status': status.value, 'status': status.value,
'package': asdict(package) 'package': package.view()
} }
try: try:
response = requests.post(self._package_url(package.base), json=payload) response = requests.post(self._package_url(package.base), json=payload)
response.raise_for_status() 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: except Exception:
self.logger.exception(f'could not add {package.base}', exc_info=True) self.logger.exception(f'could not add {package.base}', exc_info=True)
@ -85,6 +85,8 @@ class WebClient(Client):
try: try:
response = requests.delete(self._package_url(base)) response = requests.delete(self._package_url(base))
response.raise_for_status() 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: except Exception:
self.logger.exception(f'could not delete {base}', exc_info=True) self.logger.exception(f'could not delete {base}', exc_info=True)
@ -99,6 +101,8 @@ class WebClient(Client):
try: try:
response = requests.post(self._package_url(base), json=payload) response = requests.post(self._package_url(base), json=payload)
response.raise_for_status() 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: except Exception:
self.logger.exception(f'could not update {base}', exc_info=True) self.logger.exception(f'could not update {base}', exc_info=True)
@ -112,5 +116,7 @@ class WebClient(Client):
try: try:
response = requests.post(self._ahriman_url(), json=payload) response = requests.post(self._ahriman_url(), json=payload)
response.raise_for_status() 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: except Exception:
self.logger.exception('could not update service status', exc_info=True) self.logger.exception('could not update service status', exc_info=True)

View File

@ -126,7 +126,7 @@ class Package:
''' '''
packages = { packages = {
key: PackageDescription(**value) key: PackageDescription(**value)
for key, value in dump.get('packages', {}) for key, value in dump.get('packages', {}).items()
} }
return Package( return Package(
base=dump['base'], base=dump['base'],

View File

@ -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 <http://www.gnu.org/licenses/>.
#

View File

@ -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 <http://www.gnu.org/licenses/>.
#
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)

View File

@ -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 <http://www.gnu.org/licenses/>.
#
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

View File

@ -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 <http://www.gnu.org/licenses/>.
#
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)

View File

@ -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 <http://www.gnu.org/licenses/>.
#
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)
]

View File

@ -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 <http://www.gnu.org/licenses/>.
#
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