diff --git a/Makefile b/Makefile index f029861f9..6b48361c5 100644 --- a/Makefile +++ b/Makefile @@ -5,13 +5,13 @@ include Makefile.common pkgdata_BINS = $(shell find * -maxdepth 0 -executable -type f) pkgdata_SCRIPTS=$(wildcard *.py *.pl *.sh) pkgdata_SCRIPTS+=findfileconflicts publish_distro generate-release-packages verify-build-and-generatelists gocd/verify-repo-built-successful.py -pkgdata_DATA+=bs_copy metrics osclib pkglistgen $(wildcard *.pm *.testcase) +pkgdata_DATA+=bs_copy metrics osclib pkglistgen staginginstallchecker $(wildcard *.pm *.testcase) VERSION = "build-$(shell date +%F)" all: install: - install -d -m 755 $(DESTDIR)$(bindir) $(DESTDIR)$(pkgdatadir) $(DESTDIR)$(unitdir) $(DESTDIR)$(oscplugindir) $(DESTDIR)$(sysconfdir)/$(package_name) $(DESTDIR)$(grafana_provisioning_dir)/dashboards $(DESTDIR)$(grafana_provisioning_dir)/datasources $(DESTDIR)$(logdir)/$(package_name) $(DESTDIR)$(varlibdir)/osrt-staging/check-bugowner $(DESTDIR)$(varlibdir)/osrt-slsa/pkglistgen $(DESTDIR)$(varlibdir)/osrt-slsa/relpkggen + install -d -m 755 $(DESTDIR)$(bindir) $(DESTDIR)$(pkgdatadir) $(DESTDIR)$(unitdir) $(DESTDIR)$(oscplugindir) $(DESTDIR)$(sysconfdir)/$(package_name) $(DESTDIR)$(grafana_provisioning_dir)/dashboards $(DESTDIR)$(grafana_provisioning_dir)/datasources $(DESTDIR)$(logdir)/$(package_name) $(DESTDIR)$(varlibdir)/osrt-staging/check-bugowner $(DESTDIR)$(varlibdir)/osrt-staging/git-installcheck $(DESTDIR)$(varlibdir)/osrt-slsa/pkglistgen $(DESTDIR)$(varlibdir)/osrt-slsa/relpkggen for i in $(pkgdata_SCRIPTS); do install -m 755 $$i $(DESTDIR)$(pkgdatadir); done chmod 644 $(DESTDIR)$(pkgdatadir)/osc-*.py for i in $(pkgdata_DATA); do cp -a $$i $(DESTDIR)$(pkgdatadir); done diff --git a/config/osrt-git-installcheck.env.in b/config/osrt-git-installcheck.env.in new file mode 100644 index 000000000..bc7775c63 --- /dev/null +++ b/config/osrt-git-installcheck.env.in @@ -0,0 +1,24 @@ +# Copy this file to /etc/default/osrt-git-installcheck.env. +# where is the name of the unit instance (which should also be +# the name of the build service/gitea user) + +# GITEA_URL: the base URL of the Gitea instance +GITEA_URL="https://src.suse.de" + +# GITEA_ACCESS_TOKEN: the access token for the user +GITEA_ACCESS_TOKEN="" + +# GIT_ALLOW_REPOS: allowed repositories to process. +# For the installcheck bot, these need to be specified otherwise +# the bot would refuse processing even when the bot itself +# is added as a reviewer. +# Use a comma to specify multiple repositories: +# GIT_ALLOW_REPOS="products/SLFO,products/SLES" +GIT_ALLOW_REPOS="products/SLFO" + +# API_URL: the Open Build Service apiurl +API_URL="https://api.suse.de" + +# OSC_CONFIG: the path to the oscrc configuration file +# to use to interact with the Open Build Service +OSC_CONFIG="/etc/openSUSE-release-tools/oscrc.example" diff --git a/dist/package/openSUSE-release-tools.spec b/dist/package/openSUSE-release-tools.spec index fad5a16ed..987119de6 100644 --- a/dist/package/openSUSE-release-tools.spec +++ b/dist/package/openSUSE-release-tools.spec @@ -19,7 +19,7 @@ %global __provides_exclude ^perl.* %define source_dir openSUSE-release-tools %define announcer_filename factory-package-news -%define services osrt-slsa.target osrt-check-bugowner-gitea@.service osrt-relpkggen@.timer osrt-relpkggen@.service osrt-pkglistgen@.timer osrt-pkglistgen@.service +%define services osrt-slsa.target osrt-check-bugowner-gitea@.service osrt-git-installcheck@.service osrt-relpkggen@.timer osrt-relpkggen@.service osrt-pkglistgen@.timer osrt-pkglistgen@.service Name: openSUSE-release-tools Version: 0 Release: 0 @@ -240,6 +240,8 @@ Requires: build # TODO Update requirements. Requires: osclib = %{version} Requires: perl-XML-Simple +Requires: openSUSE-release-tools-scm = %{version} +Requires: openSUSE-release-tools-plat = %{version} Requires(pre): shadow BuildArch: noarch @@ -287,6 +289,7 @@ Group: Development/Tools/Other Requires: %{name} = %{version} Requires: openSUSE-release-tools-check-bugowner Requires: openSUSE-release-tools-pkglistgen +Requires: openSUSE-release-tools-repo-checker %sysusers_requires Recommends: logrotate BuildArch: noarch @@ -474,6 +477,7 @@ exit 0 %exclude %{_datadir}/%{source_dir}/metrics_release.py %exclude %{_datadir}/%{source_dir}/origin-manager.py %exclude %{_bindir}/osrt-staging-report +%exclude %{_datadir}/%{source_dir}/staginginstallchecker %exclude %{_datadir}/%{source_dir}/pkglistgen %exclude %{_datadir}/%{source_dir}/pkglistgen.py %exclude %{_datadir}/%{source_dir}/maintenance-installcheck.py @@ -542,10 +546,13 @@ exit 0 %{_datadir}/%{source_dir}/verify-repo-built-successful.py %{_sysconfdir}/openSUSE-release-tools/ibsapi %{_sysconfdir}/openSUSE-release-tools/osrt-check-bugowner-gitea.env.in +%{_sysconfdir}/openSUSE-release-tools/osrt-git-installcheck.env.in %{_sysusersdir}/%{name}.conf %{_sysusersdir}/%{name}-staging.conf %{_unitdir}/osrt-check-bugowner-gitea@.service %{_unitdir}/osrt-check-bugowner-gitea@.timer +%{_unitdir}/osrt-git-installcheck@.service +%{_unitdir}/osrt-git-installcheck@.timer %{_unitdir}/osrt-pkglistgen@.service %{_unitdir}/osrt-pkglistgen@.timer %{_unitdir}/osrt-relpkggen@.service @@ -559,6 +566,7 @@ exit 0 %dir %attr(750,osrt-slsa,osrt-slsa) %{_sharedstatedir}/osrt-slsa/relpkggen %dir %attr(750,osrt-staging,osrt-staging) %{_sharedstatedir}/osrt-staging %dir %attr(750,osrt-staging,osrt-staging) %{_sharedstatedir}/osrt-staging/check-bugowner +%dir %attr(750,osrt-staging,osrt-staging) %{_sharedstatedir}/osrt-staging/git-installcheck %files maintenance %{_bindir}/osrt-check_maintenance_incidents @@ -608,9 +616,11 @@ exit 0 %files repo-checker %{_bindir}/osrt-project-installcheck %{_bindir}/osrt-staging-installcheck +%{_bindir}/osrt-git-installcheck %{_bindir}/osrt-findfileconflicts %{_bindir}/osrt-maintenance-installcheck %{_bindir}/osrt-write_repo_susetags_file +%{_datadir}/%{source_dir}/staginginstallchecker %{_datadir}/%{source_dir}/project-installcheck.py %{_datadir}/%{source_dir}/findfileconflicts %{_datadir}/%{source_dir}/write_repo_susetags_file.pl diff --git a/git-installcheck.py b/git-installcheck.py new file mode 100755 index 000000000..d2237ecd8 --- /dev/null +++ b/git-installcheck.py @@ -0,0 +1,206 @@ +#!/usr/bin/python3 +import os +import sys +import ReviewBot + +import logging + +import traceback + +import urllib.error + +import shutil + +from osc import conf +from osclib.conf import Config +from osclib.stagingapi import StagingAPI +from staginginstallchecker.installchecker import InstallChecker, CheckResult + +from osclib.cache_manager import CacheManager + +DEFAULT_AUTOGITS_REVIEWER = "autogits_obs_staging_bot" +DEFAULT_ARCHITECTURES = "x86_64 s390x ppc64le aarch64" + +CACHEDIR = CacheManager.directory("repository-meta") + + +class GitInstallCheckBot(ReviewBot.ReviewBot): + """A review bot that runs staging-installcheck on staging QA projects""" + + def __init__(self, *args, **kwargs): + ReviewBot.ReviewBot.__init__(self, *args, **kwargs) + + conf.get_config() + + self.apiurl = conf.config["apiurl"] + + self.allowed_repositories = [] + + # This is heavily dependent on the GITEA platform + if self.platform.name != "GITEA": + raise Exception("Unsupported platform: this bot is only supported on Gitea") + + def get_git_staging_configuration(self, owner, project, commit_sha): + # FIXME: support JWCC + return self.platform.get_path( + f"repos/{owner}/{project}/raw/staging.config?ref={commit_sha}" + ).json() + + @staticmethod + def is_request_approved_by(request, approver): + for review in request.reviews: + if review.by == approver and review.state == "accepted": + # We skip dismissed reviews, so we can afford returning + # as soon as we find a matching review + return True + + return False + + @staticmethod + def get_request_from_src_rev(requests, src_rev): + for request in requests: + if request.actions[0].src_rev == src_rev: + return request + + return None + + def check_source_submission( + self, src_owner, src_project, src_rev, target_owner, target_package + ): + self.logger.info(f"Checking {src_project}: {src_owner} -> {target_owner}") + + try: + result = self.run_installcheck( + src_owner, src_project, src_rev, target_owner, target_package + ) + except Exception: + self.review_messages["declined"] = ( + f"Unhandled exception:\n\n```{traceback.format_exc()}```" + ) + return False + + if result is None: + return None + elif result.success: + self.review_messages["accepted"] = "installcheck ran successfully" + else: + self.review_messages["declined"] = "\n".join(result.comment) + + return result.success + + def run_installcheck( + self, src_owner, src_project, src_rev, target_owner, target_package + ): + """ + Runs repo_checker. + + :return: either a CheckResult, or None (should skip/retry later) + """ + + request = self.get_request_from_src_rev(self.requests, src_rev) + if not request: + self.logger.warning(f"Request for src_rev {src_rev} not found") + return None + + if f"{request._owner}/{request._repo}" not in self.allowed_repositories: + self.logger.info( + f"{request._owner}/{request._repo} is not in the allowed repositories list" + ) + return None + + base_commit = request.actions[0].tgt_rev + staging_configuration = self.get_git_staging_configuration( + target_owner, target_package, base_commit + ) + + main_project = staging_configuration["ObsProject"] + + codestream_project = ( + f"{staging_configuration['StagingProject']}:{request._pr_id}" + ) + + Config(self.apiurl, main_project) + target_config = conf.config[main_project] + + main_repo = target_config["main-repo"] + + enabled_architectures = target_config.get( + "repo_checker-arch-whitelist", DEFAULT_ARCHITECTURES + ).split(" ") + + approver = target_config.get("repo_checker-approver", DEFAULT_AUTOGITS_REVIEWER) + if not self.is_request_approved_by(request, approver): + return None + + api = StagingAPI(self.apiurl, codestream_project) + tool = InstallChecker(api, target_config) + + try: + api.get_prj_meta(codestream_project) + except urllib.error.HTTPError as e: + if e.code == 404: + return CheckResult( + success=True, comment="Staging bot didn't create a project" + ) + else: + raise + + try: + return tool.staging_installcheck( + codestream_project, main_repo, enabled_architectures, devel=True + ) + finally: + # Clean-up dynamic PR data - in the git workflow we + # have dynamic build projects, so the repo_mirrorer's stale + # object clean-up won't trigger + project_cache = os.path.join(CACHEDIR, codestream_project) + + if os.path.exists(project_cache) and not os.path.exists( + os.path.join(project_cache, ".lock") + ): + # Lock being present should actually never happen - we + # are the only users, and we run the check sequentially, + # however, let's check for a lock file anyway. Better safe + # than sorry. + self.logger.debug(f"Cleaning up {project_cache}") + shutil.rmtree(project_cache) + else: + # If the lock file is present, log an error, but don't + # try to remove it + self.logger.error( + f"{project_cache} has a lock file, and cannot be removed. This shouldn't happen. Skipping cleanup" + ) + + +class CommandLineInterface(ReviewBot.CommandLineInterface): + def __init__(self, *args, **kwargs): + super().__init__(*args, *kwargs) + self.clazz = GitInstallCheckBot + + def get_optparser(self): + parser = super().get_optparser() + + # Add bot-specific options + # If ReviewBot/Cmdln moves to ArgumentParser, we can turn this into a + # string directly and use nargs=*. + parser.add_option( + "--git-allow-repos", + default="", + help="allowed git repositories (e.g. products/SLFO,products/SLES)", + ) + + return parser + + def setup_checker(self): + instance = super().setup_checker() + + instance.allowed_repositories = self.options.git_allow_repos.split(",") + + return instance + + +if __name__ == "__main__": + app = CommandLineInterface() + logging.basicConfig(level=logging.DEBUG) + + sys.exit(app.main()) diff --git a/staging-installcheck.py b/staging-installcheck.py index 11a30dcd6..6df8d7212 100755 --- a/staging-installcheck.py +++ b/staging-installcheck.py @@ -3,179 +3,23 @@ import argparse import logging import os -import re import sys -from collections import namedtuple from urllib.error import HTTPError import osc.core -import yaml from lxml import etree as ET -from osclib.comments import CommentAPI from osclib.conf import Config -from osclib.conf import str2bool -from osclib.core import (builddepinfo, depends_on, duplicated_binaries_in_repo, - fileinfo_ext_all, repository_arch_state, - repository_path_expand, target_archs) -from osclib.repochecks import installcheck, mirror from osclib.stagingapi import StagingAPI -from osclib.memoize import memoize -SCRIPT_PATH = os.path.dirname(os.path.realpath(__file__)) -CheckResult = namedtuple('CheckResult', ('success', 'comment')) - - -class InstallChecker(object): - def __init__(self, api, config): - self.api = api - self.logger = logging.getLogger('InstallChecker') - self.commentapi = CommentAPI(api.apiurl) - - self.arch_whitelist = config.get('repo_checker-arch-whitelist') - if self.arch_whitelist: - self.arch_whitelist = set(self.arch_whitelist.split(' ')) - - self.ring_whitelist = set(config.get('repo_checker-binary-whitelist-ring', '').split(' ')) - - self.cycle_packages = config.get('repo_checker-allowed-in-cycles') - self.calculate_allowed_cycles() - - self.ignore_duplicated = set(config.get('installcheck-ignore-duplicated-binaries', '').split(' ')) - self.ignore_conflicts = set(config.get('installcheck-ignore-conflicts', '').split(' ')) - self.ignore_deletes = str2bool(config.get('installcheck-ignore-deletes', 'False')) - - def check_required_by(self, fileinfo, provides, requiredby, built_binaries, comments): - if requiredby.get('name') in built_binaries: - return True - - result = True - - # In some cases (boolean deps?) it's possible that fileinfo_ext for A - # shows that A provides cap needed by B, but fileinfo_ext for B does - # not list cap or A at all... In that case better error out and ask for - # human intervention. - dep_found = False - # In case the dep was not found, give a hint what OBS might have meant. - possible_dep = None - - # extract >= and the like - provide = provides.get('dep') - provide = provide.split(' ')[0] - comments.append('{} provides {} required by {}'.format( - fileinfo.find('name').text, provide, requiredby.get('name'))) - url = api.makeurl(['build', api.project, api.cmain_repo, 'x86_64', '_repository', requiredby.get('name') + '.rpm'], - {'view': 'fileinfo_ext'}) - reverse_fileinfo = ET.parse(osc.core.http_GET(url)).getroot() - - for require in reverse_fileinfo.findall('requires_ext'): - # extract >= and the like here too - dep = require.get('dep').split(' ')[0] - if dep != provide: - if provide in require.get('dep'): - possible_dep = require.get('dep') - continue - dep_found = True - # Whether this is provided by something being deleted - provided_found = False - # Whether this is provided by something not being deleted - alternative_found = False - for provided_by in require.findall('providedby'): - if provided_by.get('name') in built_binaries: - provided_found = True - else: - comments.append(f" also provided by {provided_by.get('name')} -> ignoring") - alternative_found = True - - if not alternative_found: - result = False - - if not provided_found: - comments.append(" OBS doesn't see this in the reverse resolution though. Not sure what to do.") - result = False - - if not dep_found: - comments.append(" OBS doesn't see this dep in reverse though. Not sure what to do.") - if possible_dep is not None: - comments.append(f' Might be required by {possible_dep}') - return False - - if result: - return True - else: - comments.append(f'Error: missing alternative provides for {provide}') - return False - - @memoize(session=True) - def pkg_with_multibuild_flavors(self, package): - ret = set([package]) - # Add all multibuild flavors - mainprjresult = ET.fromstringlist(osc.core.show_results_meta(self.api.apiurl, self.api.project, multibuild=True)) - for pkg in mainprjresult.xpath(f"result/status[starts-with(@package,'{package}:')]"): - ret.add(pkg.get('package')) - - return ret - - def check_delete_request(self, req, to_ignore, to_delete, comments): - package = req.get('package') - if package in to_ignore or self.ignore_deletes: - self.logger.info(f'Delete request for package {package} ignored') - return True - - pkg_flavors = self.pkg_with_multibuild_flavors(package) - - built_binaries = set() - file_infos = [] - for flavor in pkg_flavors: - for fileinfo in fileinfo_ext_all(self.api.apiurl, self.api.project, self.api.cmain_repo, 'x86_64', flavor): - built_binaries.add(fileinfo.find('name').text) - file_infos.append(fileinfo) - # extend the others - this asks for a refactoring, but we don't handle tons of delete requests often - for ptd in to_delete: - if ptd in pkg_flavors: - continue - for fileinfo in fileinfo_ext_all(self.api.apiurl, self.api.project, self.api.cmain_repo, 'x86_64', ptd): - built_binaries.add(fileinfo.find('name').text) - - result = True - for fileinfo in file_infos: - for provides in fileinfo.findall('provides_ext'): - for requiredby in provides.findall('requiredby[@name]'): - result = result and self.check_required_by(fileinfo, provides, requiredby, built_binaries, comments) - - what_depends_on = depends_on(api.apiurl, api.project, api.cmain_repo, pkg_flavors, True) - - # filter out packages to be deleted - for ptd in to_delete: - if ptd in what_depends_on: - what_depends_on.remove(ptd) +from staginginstallchecker.installchecker import InstallChecker - if len(what_depends_on): - comments.append('{} is still a build requirement of:\n\n- {}'.format( - package, '\n- '.join(sorted(what_depends_on)))) - return False - - return result - - def packages_to_ignore(self, project): - comments = self.commentapi.get_comments(project_name=project) - ignore_re = re.compile(r'^installcheck: ignore (?P.*)$', re.MULTILINE) +SCRIPT_PATH = os.path.dirname(os.path.realpath(__file__)) - # the last wins, for now we don't care who said it - args = [] - for comment in comments.values(): - match = ignore_re.search(comment['comment'].replace('\r', '')) - if not match: - continue - args = match.group('args').strip() - # allow space and comma to seperate - args = args.replace(',', ' ').split(' ') - return set(args) +class OBSInstallChecker(InstallChecker): def staging(self, project, repository, force=False, devel=False): - api = self.api - # fetch the build ids at the beginning - mirroring takes a while buildids = {} try: @@ -209,87 +53,17 @@ def staging(self, project, repository, force=False, devel=False): if all_done and not force: return True - repository_pairs = repository_path_expand(api.apiurl, project, repository) - result_comment = [] + result = self.staging_installcheck(project, repository, architectures, devel=devel) - result = True - to_ignore = self.packages_to_ignore(project) if not devel: - status = api.project_status(project) - if status is None: - self.logger.error(f'no project status for {project}') - return False - - # collect packages to be deleted - to_delete = set() - for req in status.findall('staged_requests/request'): - if req.get('type') == 'delete': - to_delete |= self.pkg_with_multibuild_flavors(req.get('package')) - - for req in status.findall('staged_requests/request'): - if req.get('type') == 'delete': - result = self.check_delete_request(req, to_ignore, to_delete, result_comment) and result - - for arch in architectures: - # hit the first repository in the target project (if existant) - target_pair = None - directories = [] - for pair_project, pair_repository in repository_pairs: - # ignore repositories only inherited for config - if repository_arch_state(self.api.apiurl, pair_project, pair_repository, arch): - if not target_pair and pair_project == api.project: - target_pair = [pair_project, pair_repository] - - directories.append(mirror(self.api.apiurl, pair_project, pair_repository, arch)) - - if not api.is_adi_project(project): - # For "leaky" ring packages in letter stagings, where the - # repository setup does not include the target project, that are - # not intended to to have all run-time dependencies satisfied. - whitelist = self.ring_whitelist - else: - whitelist = set() - - whitelist |= to_ignore - ignore_conflicts = self.ignore_conflicts | to_ignore - - check = self.cycle_check(project, repository, arch) - if not check.success: - self.logger.warning('Cycle check failed') - result_comment.append(check.comment) - result = False - - check = self.install_check(directories, arch, whitelist, ignore_conflicts) - if not check.success: - self.logger.warning('Install check failed') - result_comment.append(check.comment) - result = False - - duplicates = duplicated_binaries_in_repo(self.api.apiurl, project, repository) - # remove white listed duplicates - for arch in list(duplicates): - for binary in self.ignore_duplicated: - duplicates[arch].pop(binary, None) - if not len(duplicates[arch]): - del duplicates[arch] - if len(duplicates): - self.logger.warning('Found duplicated binaries') - result_comment.append('Found duplicated binaries') - result_comment.append(yaml.dump(duplicates, default_flow_style=False)) - result = False - - if devel: - print(project, '\n'.join(result_comment)) - else: - if result: + if result.success: self.report_state('success', self.gocd_url(), project, repository, buildids) else: - result_comment.insert(0, f'Generated from {self.gocd_url()}\n') - self.report_state('failure', self.upload_failure(project, result_comment), project, repository, buildids) + result.comment.insert(0, f'Generated from {self.gocd_url()}\n') + self.report_state('failure', self.upload_failure(project, result.comment), project, repository, buildids) self.logger.warning(f'Not accepting {project}') - return False - return result + return result.success def upload_failure(self, project, comment): print(project, '\n'.join(comment)) @@ -348,57 +122,6 @@ def check_xml(self, url, state, name): se.text = name return ET.tostring(check) - def target_archs(self, project, repository): - archs = target_archs(self.api.apiurl, project, repository) - - # Check for arch whitelist and use intersection. - if self.arch_whitelist: - archs = list(self.arch_whitelist.intersection(set(archs))) - - # Trick to prioritize x86_64. - return sorted(archs, reverse=True) - - def install_check(self, directories, arch, whitelist, ignored_conflicts): - self.logger.info(f"install check: start (whitelist:{','.join(whitelist)})") - parts = installcheck(directories, arch, whitelist, ignored_conflicts) - if len(parts): - header = f'### [install check & file conflicts for {arch}]' - return CheckResult(False, header + '\n\n' + ('\n' + ('-' * 80) + '\n\n').join(parts)) - - self.logger.info('install check: passed') - return CheckResult(True, None) - - def calculate_allowed_cycles(self): - self.allowed_cycles = [] - if self.cycle_packages: - for comma_list in self.cycle_packages.split(';'): - self.allowed_cycles.append(comma_list.split(',')) - - def cycle_check(self, project, repository, arch): - self.logger.info(f'cycle check: start {project}/{repository}/{arch}') - comment = [] - - depinfo = builddepinfo(self.api.apiurl, project, repository, arch, order=False) - for cycle in depinfo.findall('cycle'): - for package in cycle.findall('package'): - package = package.text - allowed = False - for acycle in self.allowed_cycles: - if package in acycle: - allowed = True - break - if not allowed: - cycled = [p.text for p in cycle.findall('package')] - comment.append(f"Package {package} appears in cycle {'/'.join(cycled)}") - - if len(comment): - # New cycles, post comment. - self.logger.info('cycle check: failed') - return CheckResult(False, '\n'.join(comment) + '\n') - - self.logger.info('cycle check: passed') - return CheckResult(True, None) - if __name__ == '__main__': parser = argparse.ArgumentParser( @@ -425,7 +148,7 @@ def cycle_check(self, project, repository, arch): api = StagingAPI(apiurl, args.project) if not args.repository: args.repository = api.cmain_repo - staging_report = InstallChecker(api, config) + staging_report = OBSInstallChecker(api, config) if args.debug: logging.basicConfig(level=logging.DEBUG) diff --git a/staginginstallchecker/__init__.py b/staginginstallchecker/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/staginginstallchecker/installchecker.py b/staginginstallchecker/installchecker.py new file mode 100644 index 000000000..c086c0548 --- /dev/null +++ b/staginginstallchecker/installchecker.py @@ -0,0 +1,303 @@ +import logging +import os +import re +from collections import namedtuple + +import osc.core +import yaml +from lxml import etree as ET + +from osclib.comments import CommentAPI +from osclib.conf import str2bool +from osclib.core import (builddepinfo, depends_on, duplicated_binaries_in_repo, + fileinfo_ext_all, repository_arch_state, + repository_path_expand, target_archs) + +from osclib.repochecks import installcheck, mirror +from osclib.memoize import memoize + +SCRIPT_PATH = os.path.dirname(os.path.realpath(__file__)) +CheckResult = namedtuple('CheckResult', ('success', 'comment')) + + +class InstallChecker(object): + def __init__(self, api, config): + self.api = api + self.logger = logging.getLogger('InstallChecker') + self.commentapi = CommentAPI(api.apiurl) + + self.arch_whitelist = config.get('repo_checker-arch-whitelist') + if self.arch_whitelist: + self.arch_whitelist = set(self.arch_whitelist.split(' ')) + + self.ring_whitelist = set(config.get('repo_checker-binary-whitelist-ring', '').split(' ')) + + self.cycle_packages = config.get('repo_checker-allowed-in-cycles') + self.calculate_allowed_cycles() + + self.ignore_duplicated = set(config.get('installcheck-ignore-duplicated-binaries', '').split(' ')) + self.ignore_conflicts = set(config.get('installcheck-ignore-conflicts', '').split(' ')) + self.ignore_deletes = str2bool(config.get('installcheck-ignore-deletes', 'False')) + + def check_required_by(self, fileinfo, provides, requiredby, built_binaries, comments): + if requiredby.get('name') in built_binaries: + return True + + result = True + + # In some cases (boolean deps?) it's possible that fileinfo_ext for A + # shows that A provides cap needed by B, but fileinfo_ext for B does + # not list cap or A at all... In that case better error out and ask for + # human intervention. + dep_found = False + # In case the dep was not found, give a hint what OBS might have meant. + possible_dep = None + + # extract >= and the like + provide = provides.get('dep') + provide = provide.split(' ')[0] + comments.append('{} provides {} required by {}'.format( + fileinfo.find('name').text, provide, requiredby.get('name'))) + url = self.api.makeurl(['build', self.api.project, self.api.cmain_repo, 'x86_64', '_repository', requiredby.get('name') + '.rpm'], + {'view': 'fileinfo_ext'}) + reverse_fileinfo = ET.parse(osc.core.http_GET(url)).getroot() + + for require in reverse_fileinfo.findall('requires_ext'): + # extract >= and the like here too + dep = require.get('dep').split(' ')[0] + if dep != provide: + if provide in require.get('dep'): + possible_dep = require.get('dep') + continue + dep_found = True + # Whether this is provided by something being deleted + provided_found = False + # Whether this is provided by something not being deleted + alternative_found = False + for provided_by in require.findall('providedby'): + if provided_by.get('name') in built_binaries: + provided_found = True + else: + comments.append(f" also provided by {provided_by.get('name')} -> ignoring") + alternative_found = True + + if not alternative_found: + result = False + + if not provided_found: + comments.append(" OBS doesn't see this in the reverse resolution though. Not sure what to do.") + result = False + + if not dep_found: + comments.append(" OBS doesn't see this dep in reverse though. Not sure what to do.") + if possible_dep is not None: + comments.append(f' Might be required by {possible_dep}') + return False + + if result: + return True + else: + comments.append(f'Error: missing alternative provides for {provide}') + return False + + @memoize(session=True) + def pkg_with_multibuild_flavors(self, package): + ret = set([package]) + # Add all multibuild flavors + mainprjresult = ET.fromstringlist(osc.core.show_results_meta(self.api.apiurl, self.api.project, multibuild=True)) + for pkg in mainprjresult.xpath(f"result/status[starts-with(@package,'{package}:')]"): + ret.add(pkg.get('package')) + + return ret + + def check_delete_request(self, req, to_ignore, to_delete, comments): + package = req.get('package') + if package in to_ignore or self.ignore_deletes: + self.logger.info(f'Delete request for package {package} ignored') + return True + + pkg_flavors = self.pkg_with_multibuild_flavors(package) + + built_binaries = set() + file_infos = [] + for flavor in pkg_flavors: + for fileinfo in fileinfo_ext_all(self.api.apiurl, self.api.project, self.api.cmain_repo, 'x86_64', flavor): + built_binaries.add(fileinfo.find('name').text) + file_infos.append(fileinfo) + # extend the others - this asks for a refactoring, but we don't handle tons of delete requests often + for ptd in to_delete: + if ptd in pkg_flavors: + continue + for fileinfo in fileinfo_ext_all(self.api.apiurl, self.api.project, self.api.cmain_repo, 'x86_64', ptd): + built_binaries.add(fileinfo.find('name').text) + + result = True + for fileinfo in file_infos: + for provides in fileinfo.findall('provides_ext'): + for requiredby in provides.findall('requiredby[@name]'): + result = result and self.check_required_by(fileinfo, provides, requiredby, built_binaries, comments) + + what_depends_on = depends_on(self.api.apiurl, self.api.project, self.api.cmain_repo, pkg_flavors, True) + + # filter out packages to be deleted + for ptd in to_delete: + if ptd in what_depends_on: + what_depends_on.remove(ptd) + + if len(what_depends_on): + comments.append('{} is still a build requirement of:\n\n- {}'.format( + package, '\n- '.join(sorted(what_depends_on)))) + return False + + return result + + def packages_to_ignore(self, project): + comments = self.commentapi.get_comments(project_name=project) + ignore_re = re.compile(r'^installcheck: ignore (?P.*)$', re.MULTILINE) + + # the last wins, for now we don't care who said it + args = [] + for comment in comments.values(): + match = ignore_re.search(comment['comment'].replace('\r', '')) + if not match: + continue + args = match.group('args').strip() + # allow space and comma to seperate + args = args.replace(',', ' ').split(' ') + return set(args) + + def staging_installcheck(self, project, repository, architectures, devel=False): + api = self.api + + repository_pairs = repository_path_expand(api.apiurl, project, repository) + result_comment = [] + + result = True + to_ignore = self.packages_to_ignore(project) + if not devel: + status = api.project_status(project) + if status is None: + self.logger.error(f'no project status for {project}') + return False + + # collect packages to be deleted + to_delete = set() + for req in status.findall('staged_requests/request'): + if req.get('type') == 'delete': + to_delete |= self.pkg_with_multibuild_flavors(req.get('package')) + + for req in status.findall('staged_requests/request'): + if req.get('type') == 'delete': + result = self.check_delete_request(req, to_ignore, to_delete, result_comment) and result + + for arch in architectures: + # hit the first repository in the target project (if existant) + target_pair = None + directories = [] + for pair_project, pair_repository in repository_pairs: + # ignore repositories only inherited for config + if repository_arch_state(self.api.apiurl, pair_project, pair_repository, arch): + if not target_pair and pair_project == api.project: + target_pair = [pair_project, pair_repository] + + directories.append(mirror(self.api.apiurl, pair_project, pair_repository, arch)) + + if not api.is_adi_project(project): + # For "leaky" ring packages in letter stagings, where the + # repository setup does not include the target project, that are + # not intended to to have all run-time dependencies satisfied. + whitelist = self.ring_whitelist + else: + whitelist = set() + + whitelist |= to_ignore + ignore_conflicts = self.ignore_conflicts | to_ignore + + check = self.cycle_check(project, repository, arch) + if not check.success: + self.logger.warning('Cycle check failed') + result_comment.append(check.comment) + result = False + + check = self.install_check(directories, arch, whitelist, ignore_conflicts) + if not check.success: + self.logger.warning('Install check failed') + result_comment.append(check.comment) + result = False + + duplicates = duplicated_binaries_in_repo(self.api.apiurl, project, repository) + # remove white listed duplicates + for arch in list(duplicates): + for binary in self.ignore_duplicated: + duplicates[arch].pop(binary, None) + if not len(duplicates[arch]): + del duplicates[arch] + if len(duplicates): + self.logger.warning('Found duplicated binaries') + result_comment.append('Found duplicated binaries') + result_comment.append(yaml.dump(duplicates, default_flow_style=False)) + result = False + + if devel: + print(project, '\n'.join(result_comment)) + + return CheckResult(result, result_comment) + + def buildid(self, project, repository, architecture): + url = self.api.makeurl(['build', project, repository, architecture], {'view': 'status'}) + root = ET.parse(osc.core.http_GET(url)).getroot() + buildid = root.find('buildid') + if buildid is None: + return False + return buildid.text + + def target_archs(self, project, repository): + archs = target_archs(self.api.apiurl, project, repository) + + # Check for arch whitelist and use intersection. + if self.arch_whitelist: + archs = list(self.arch_whitelist.intersection(set(archs))) + + # Trick to prioritize x86_64. + return sorted(archs, reverse=True) + + def install_check(self, directories, arch, whitelist, ignored_conflicts): + self.logger.info(f"install check: start (whitelist:{','.join(whitelist)})") + parts = installcheck(directories, arch, whitelist, ignored_conflicts) + if len(parts): + header = f'### [install check & file conflicts for {arch}]' + return CheckResult(False, header + '\n\n' + ('\n' + ('-' * 80) + '\n\n').join(parts)) + + self.logger.info('install check: passed') + return CheckResult(True, None) + + def calculate_allowed_cycles(self): + self.allowed_cycles = [] + if self.cycle_packages: + for comma_list in self.cycle_packages.split(';'): + self.allowed_cycles.append(comma_list.split(',')) + + def cycle_check(self, project, repository, arch): + self.logger.info(f'cycle check: start {project}/{repository}/{arch}') + comment = [] + + depinfo = builddepinfo(self.api.apiurl, project, repository, arch, order=False) + for cycle in depinfo.findall('cycle'): + for package in cycle.findall('package'): + package = package.text + allowed = False + for acycle in self.allowed_cycles: + if package in acycle: + allowed = True + break + if not allowed: + cycled = [p.text for p in cycle.findall('package')] + comment.append(f"Package {package} appears in cycle {'/'.join(cycled)}") + + if len(comment): + # New cycles, post comment. + self.logger.info('cycle check: failed') + return CheckResult(False, '\n'.join(comment) + '\n') + + self.logger.info('cycle check: passed') + return CheckResult(True, None) diff --git a/systemd/osrt-git-installcheck@.service b/systemd/osrt-git-installcheck@.service new file mode 100644 index 000000000..c0f3b2d18 --- /dev/null +++ b/systemd/osrt-git-installcheck@.service @@ -0,0 +1,16 @@ +[Unit] +PartOf=osrt-slsa.target +Description=service for osrt-git-installcheck on gitea running as %i +After=network-online.target + +[Service] +Type=oneshot +User=osrt-staging +SyslogIdentifier=osrt-slsa +EnvironmentFile=/etc/default/osrt-git-installcheck.env.%i +WorkingDirectory=/var/lib/osrt-staging/git-installcheck +ExecStartPre=/bin/bash -xc '/usr/bin/systemctl is-active --quiet osrt-git-installcheck@%i.service && exit 1 || exit 0' +ExecStart=/usr/bin/osrt-git-installcheck --scm=git --platform=gitea --git-allow-repos="${GIT_ALLOW_REPOS}" --gitea-url="${GITEA_URL}" --user="%i" review + +[Install] +WantedBy=osrt-slsa.target diff --git a/systemd/osrt-git-installcheck@.timer b/systemd/osrt-git-installcheck@.timer new file mode 100644 index 000000000..81fcd5bf0 --- /dev/null +++ b/systemd/osrt-git-installcheck@.timer @@ -0,0 +1,9 @@ +[Unit] +Description=timer for osrt-git-installcheck on gitea running as %i + +[Timer] +OnBootSec=2min +OnUnitActiveSec=10min + +[Install] +WantedBy=timers.target