Distinct copyrights were left as I do not wish to track down commit history to ensure it properly documents the copyright holders. Also left non-GPLv2 licenses and left bs_copy untouched as a mirror from OBS. Already have a mix of with and without headers and even OBS does not place on majority of files. If SUSE lawyers have an issue it will come up in legal review for Factory.
1091 lines
43 KiB
Python
Executable File
1091 lines
43 KiB
Python
Executable File
#!/usr/bin/python
|
|
|
|
from optparse import OptionParser
|
|
from pprint import pformat, pprint
|
|
from stat import S_ISREG, S_ISLNK
|
|
from tempfile import NamedTemporaryFile
|
|
import cmdln
|
|
import logging
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import abichecker_dbmodel as DB
|
|
import sqlalchemy.orm.exc
|
|
|
|
try:
|
|
from xml.etree import cElementTree as ET
|
|
except ImportError:
|
|
import cElementTree as ET
|
|
|
|
import osc.conf
|
|
import osc.core
|
|
from osc.util.cpio import CpioRead
|
|
|
|
import urllib2
|
|
import rpm
|
|
from collections import namedtuple
|
|
from osclib.pkgcache import PkgCache
|
|
from osclib.comments import CommentAPI
|
|
|
|
from abichecker_common import CACHEDIR
|
|
|
|
import ReviewBot
|
|
|
|
WEB_URL=None
|
|
|
|
# build mapping between source repos and target repos
|
|
MR = namedtuple('MatchRepo', ('srcrepo', 'dstrepo', 'arch'))
|
|
|
|
# FIXME: use attribute instead
|
|
PROJECT_BLACKLIST = {
|
|
'SUSE:SLE-11:Update' : "abi-checker doesn't support SLE 11",
|
|
'SUSE:SLE-11-SP1:Update' : "abi-checker doesn't support SLE 11",
|
|
'SUSE:SLE-11-SP2:Update' : "abi-checker doesn't support SLE 11",
|
|
'SUSE:SLE-11-SP3:Update' : "abi-checker doesn't support SLE 11",
|
|
'SUSE:SLE-11-SP4:Update' : "abi-checker doesn't support SLE 11",
|
|
}
|
|
|
|
# some project have more repos than what we are interested in
|
|
REPO_WHITELIST = {
|
|
'openSUSE:Factory': ('standard', 'snapshot'),
|
|
'openSUSE:13.1:Update': 'standard',
|
|
'openSUSE:13.2:Update': 'standard',
|
|
|
|
'SUSE:SLE-12:Update' : 'standard',
|
|
}
|
|
|
|
# same for arch
|
|
ARCH_WHITELIST = {
|
|
'SUSE:SLE-12:Update' : ('i586', 'ppc64le', 's390', 's390x', 'x86_64'),
|
|
}
|
|
|
|
# Directory where download binary packages.
|
|
# TODO: move to CACHEDIR, just here for consistency with repochecker
|
|
BINCACHE = os.path.expanduser('~/co')
|
|
DOWNLOADS = os.path.join(BINCACHE, 'downloads')
|
|
|
|
# Where the cache files are stored
|
|
UNPACKDIR = os.path.join(CACHEDIR, 'unpacked')
|
|
|
|
so_re = re.compile(r'^(?:/usr)?/lib(?:64)?/lib([^/]+)\.so(?:\.[^/]+)?')
|
|
debugpkg_re = re.compile(r'-debug(?:source|info)(?:-(?:32|64)bit)?$')
|
|
disturl_re = re.compile(r'^obs://[^/]+/(?P<prj>[^/]+)/(?P<repo>[^/]+)/(?P<md5>[0-9a-f]{32})-(?P<pkg>.*)$')
|
|
|
|
comment_marker_re = re.compile(r'<!-- abichecker state=(?P<state>done|seen)(?: result=(?P<result>accepted|declined))? -->')
|
|
|
|
# report for source submissions. contains multiple libresult for each library
|
|
Report = namedtuple('Report', ('src_project', 'src_package', 'src_rev', 'dst_project', 'dst_package', 'reports', 'result'))
|
|
# report for a single library
|
|
LibResult = namedtuple('LibResult', ('src_repo', 'src_lib', 'dst_repo', 'dst_lib', 'arch', 'htmlreport', 'result'))
|
|
|
|
|
|
class DistUrlMismatch(Exception):
|
|
def __init__(self, disturl, md5):
|
|
Exception.__init__(self)
|
|
self.msg = 'disturl mismatch has: %s wanted ...%s'%(disturl, md5)
|
|
|
|
def __str__(self):
|
|
return self.msg
|
|
|
|
|
|
class SourceBroken(Exception):
|
|
def __init__(self, project, package):
|
|
Exception.__init__(self)
|
|
self.msg = '%s/%s has broken sources, needs rebase'%(project, package)
|
|
|
|
def __str__(self):
|
|
return self.msg
|
|
|
|
|
|
class NoBuildSuccess(Exception):
|
|
def __init__(self, project, package, md5):
|
|
Exception.__init__(self)
|
|
self.msg = '%s/%s(%s) had no successful build'%(project, package, md5)
|
|
|
|
def __str__(self):
|
|
return self.msg
|
|
|
|
|
|
class NotReadyYet(Exception):
|
|
def __init__(self, project, package, reason):
|
|
Exception.__init__(self)
|
|
self.msg = '%s/%s not ready yet: %s'%(project, package, reason)
|
|
|
|
def __str__(self):
|
|
return self.msg
|
|
|
|
|
|
class MissingDebugInfo(Exception):
|
|
def __init__(self, missing_debuginfo):
|
|
Exception.__init__(self)
|
|
self.msg = ''
|
|
for i in missing_debuginfo:
|
|
if len(i) == 6:
|
|
self.msg += "%s/%s %s/%s %s %s\n"%i
|
|
elif len(i) == 5:
|
|
self.msg += "%s/%s %s/%s %s\n"%i
|
|
|
|
def __str__(self):
|
|
return self.msg
|
|
|
|
|
|
class FetchError(Exception):
|
|
def __init__(self, msg):
|
|
Exception.__init__(self)
|
|
self.msg = msg
|
|
|
|
def __str__(self):
|
|
return self.msg
|
|
|
|
|
|
class MaintenanceError(Exception):
|
|
def __init__(self, msg):
|
|
Exception.__init__(self)
|
|
self.msg = msg
|
|
|
|
def __str__(self):
|
|
return self.msg
|
|
|
|
|
|
class LogToDB(logging.Filter):
|
|
def __init__(self, session):
|
|
self.session = session
|
|
self.request_id = None
|
|
|
|
def filter(self, record):
|
|
if self.request_id is not None and record.levelno >= logging.INFO:
|
|
logentry = DB.Log(request_id = self.request_id, line = record.getMessage())
|
|
self.session.add(logentry)
|
|
self.session.commit()
|
|
return True
|
|
|
|
|
|
class ABIChecker(ReviewBot.ReviewBot):
|
|
""" check ABI of library packages
|
|
"""
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
ReviewBot.ReviewBot.__init__(self, *args, **kwargs)
|
|
|
|
self.no_review = False
|
|
self.force = False
|
|
|
|
self.ts = rpm.TransactionSet()
|
|
self.ts.setVSFlags(rpm._RPMVSF_NOSIGNATURES)
|
|
|
|
self.pkgcache = PkgCache(BINCACHE)
|
|
|
|
# reports of source submission
|
|
self.reports = []
|
|
# textual report summary for use in accept/decline message
|
|
# or comments
|
|
self.text_summary = ''
|
|
|
|
self.session = DB.db_session()
|
|
|
|
self.dblogger = LogToDB(self.session)
|
|
|
|
self.logger.addFilter(self.dblogger)
|
|
|
|
self.commentapi = CommentAPI(self.apiurl)
|
|
|
|
def check_source_submission(self, src_project, src_package, src_rev, dst_project, dst_package):
|
|
|
|
# happens for maintenance incidents
|
|
if dst_project == None and src_package == 'patchinfo':
|
|
return None
|
|
|
|
if dst_project in PROJECT_BLACKLIST:
|
|
self.logger.info(PROJECT_BLACKLIST[dst_project])
|
|
# self.text_summary += PROJECT_BLACKLIST[dst_project] + "\n"
|
|
return True
|
|
|
|
# default is to accept the review, just leave a note if
|
|
# there were problems.
|
|
ret = True
|
|
|
|
ReviewBot.ReviewBot.check_source_submission(self, src_project, src_package, src_rev, dst_project, dst_package)
|
|
|
|
report = Report(src_project, src_package, src_rev, dst_project, dst_package, [], None)
|
|
|
|
dst_srcinfo = self.get_sourceinfo(dst_project, dst_package)
|
|
self.logger.debug('dest sourceinfo %s', pformat(dst_srcinfo))
|
|
if dst_srcinfo is None:
|
|
msg = "%s/%s seems to be a new package, no need to review"%(dst_project, dst_package)
|
|
self.logger.info(msg)
|
|
self.text_summary += msg + "\n"
|
|
self.reports.append(report)
|
|
return True
|
|
src_srcinfo = self.get_sourceinfo(src_project, src_package, src_rev)
|
|
self.logger.debug('src sourceinfo %s', pformat(src_srcinfo))
|
|
if src_srcinfo is None:
|
|
msg = "%s/%s@%s does not exist!? can't check"%(src_project, src_package, src_rev)
|
|
self.logger.error(msg)
|
|
self.text_summary += msg + "\n"
|
|
self.reports.append(report)
|
|
return False
|
|
|
|
if os.path.exists(UNPACKDIR):
|
|
shutil.rmtree(UNPACKDIR)
|
|
|
|
try:
|
|
# compute list of common repos to find out what to compare
|
|
myrepos = self.findrepos(src_project, src_srcinfo, dst_project, dst_srcinfo)
|
|
except NoBuildSuccess as e:
|
|
self.logger.info(e)
|
|
self.text_summary += "**Error**: %s\n"%e
|
|
self.reports.append(report)
|
|
return False
|
|
except NotReadyYet as e:
|
|
self.logger.info(e)
|
|
self.reports.append(report)
|
|
return None
|
|
except SourceBroken as e:
|
|
self.logger.error(e)
|
|
self.text_summary += "**Error**: %s\n"%e
|
|
self.reports.append(report)
|
|
return False
|
|
|
|
if not myrepos:
|
|
self.text_summary += "**Error**: %s does not build against %s, can't check library ABIs\n\n"%(src_project, dst_project)
|
|
self.logger.info("no matching repos, can't compare")
|
|
self.reports.append(report)
|
|
return False
|
|
|
|
# *** beware of nasty maintenance stuff ***
|
|
# if the destination is a maintained project we need to
|
|
# mangle our comparison target and the repo mapping
|
|
try:
|
|
originproject, originpackage, origin_srcinfo, new_repo_map = self._maintenance_hack(dst_project, dst_srcinfo, myrepos)
|
|
if originproject is not None:
|
|
dst_project = originproject
|
|
if originpackage is not None:
|
|
dst_package = originpackage
|
|
if origin_srcinfo is not None:
|
|
dst_srcinfo = origin_srcinfo
|
|
if new_repo_map is not None:
|
|
myrepos = new_repo_map
|
|
except MaintenanceError as e:
|
|
self.text_summary += "**Error**: %s\n\n"%e
|
|
self.logger.error('%s', e)
|
|
self.reports.append(report)
|
|
return False
|
|
except NoBuildSuccess as e:
|
|
self.logger.info(e)
|
|
self.text_summary += "**Error**: %s\n"%e
|
|
self.reports.append(report)
|
|
return False
|
|
except NotReadyYet as e:
|
|
self.logger.info(e)
|
|
self.reports.append(report)
|
|
return None
|
|
except SourceBroken as e:
|
|
self.logger.error(e)
|
|
self.text_summary += "**Error**: %s\n"%e
|
|
self.reports.append(report)
|
|
return False
|
|
|
|
notes = []
|
|
libresults = []
|
|
|
|
overall = None
|
|
|
|
missing_debuginfo = []
|
|
|
|
for mr in myrepos:
|
|
try:
|
|
dst_libs = self.extract(dst_project, dst_package, dst_srcinfo, mr.dstrepo, mr.arch)
|
|
# nothing to fetch, so no libs
|
|
if dst_libs is None:
|
|
continue
|
|
except DistUrlMismatch as e:
|
|
self.logger.error("%s/%s %s/%s: %s"%(dst_project, dst_package, mr.dstrepo, mr.arch, e))
|
|
if ret == True: # need to check again
|
|
ret = None
|
|
continue
|
|
except MissingDebugInfo as e:
|
|
missing_debuginfo.append(str(e))
|
|
ret = False
|
|
continue
|
|
except FetchError as e:
|
|
self.logger.error(e)
|
|
if ret == True: # need to check again
|
|
ret = None
|
|
continue
|
|
|
|
try:
|
|
src_libs = self.extract(src_project, src_package, src_srcinfo, mr.srcrepo, mr.arch)
|
|
if src_libs is None:
|
|
if dst_libs:
|
|
self.text_summary += "*Warning*: the submission does not contain any libs anymore\n\n"
|
|
continue
|
|
except DistUrlMismatch as e:
|
|
self.logger.error("%s/%s %s/%s: %s"%(src_project, src_package, mr.srcrepo, mr.arch, e))
|
|
if ret == True: # need to check again
|
|
ret = None
|
|
continue
|
|
except MissingDebugInfo as e:
|
|
missing_debuginfo.append(str(e))
|
|
ret = False
|
|
continue
|
|
except FetchError as e:
|
|
self.logger.error(e)
|
|
if ret == True: # need to check again
|
|
ret = None
|
|
continue
|
|
|
|
# create reverse index for aliases in the source project
|
|
src_aliases = dict()
|
|
for lib in src_libs.keys():
|
|
for a in src_libs[lib]:
|
|
src_aliases.setdefault(a, set()).add(lib)
|
|
|
|
# for each library in the destination project check if the same lib
|
|
# exists in the source project. If not check the aliases (symlinks)
|
|
# to catch soname changes. Generate pairs of matching libraries.
|
|
pairs = set()
|
|
for lib in dst_libs.keys():
|
|
if lib in src_libs:
|
|
pairs.add((lib, lib))
|
|
else:
|
|
self.logger.debug("%s not found in submission, checking aliases", lib)
|
|
found = False
|
|
for a in dst_libs[lib]:
|
|
if a in src_aliases:
|
|
for l in src_aliases[a]:
|
|
pairs.add((lib, l))
|
|
found = True
|
|
if found == False:
|
|
self.text_summary += "*Warning*: %s no longer packaged\n\n"%lib
|
|
|
|
self.logger.debug("to diff: %s", pformat(pairs))
|
|
|
|
# for each pair dump and compare the abi
|
|
for old, new in pairs:
|
|
# abi dump of old lib
|
|
new_base = os.path.join(UNPACKDIR, dst_project, dst_package, mr.dstrepo, mr.arch)
|
|
old_dump = os.path.join(CACHEDIR, 'old.dump')
|
|
# abi dump of new lib
|
|
old_base = os.path.join(UNPACKDIR, src_project, src_package, mr.srcrepo, mr.arch)
|
|
new_dump = os.path.join(CACHEDIR, 'new.dump')
|
|
|
|
def cleanup():
|
|
if os.path.exists(old_dump):
|
|
os.unlink(old_dump)
|
|
if os.path.exists(new_dump):
|
|
os.unlink(new_dump)
|
|
|
|
cleanup()
|
|
|
|
# we just need that to pass a name to abi checker
|
|
m = so_re.match(old)
|
|
htmlreport = 'report-%s-%s-%s-%s-%s-%08x.html'%(mr.srcrepo, os.path.basename(old), mr.dstrepo, os.path.basename(new), mr.arch, time.time())
|
|
|
|
# run abichecker
|
|
if m \
|
|
and self.run_abi_dumper(old_dump, new_base, old) \
|
|
and self.run_abi_dumper(new_dump, old_base, new):
|
|
reportfn = os.path.join(CACHEDIR, htmlreport)
|
|
r = self.run_abi_checker(m.group(1), old_dump, new_dump, reportfn)
|
|
if r is not None:
|
|
self.logger.debug('report saved to %s, compatible: %d', reportfn, r)
|
|
libresults.append(LibResult(mr.srcrepo, os.path.basename(old), mr.dstrepo, os.path.basename(new), mr.arch, htmlreport, r))
|
|
if overall is None:
|
|
overall = r
|
|
elif overall == True and r == False:
|
|
overall = r
|
|
else:
|
|
self.logger.error('failed to compare %s <> %s'%(old,new))
|
|
self.text_summary += "**Error**: ABI check failed on %s vs %s\n\n"%(old, new)
|
|
if ret == True: # need to check again
|
|
ret = None
|
|
|
|
cleanup()
|
|
|
|
if missing_debuginfo:
|
|
self.text_summary += 'debug information is missing for the following packages, can\'t check:\n<pre>'
|
|
self.text_summary += ''.join(missing_debuginfo)
|
|
self.text_summary += '</pre>\nplease enable debug info in your project config.\n'
|
|
|
|
self.reports.append(report._replace(result = overall, reports = libresults))
|
|
|
|
# upload reports
|
|
|
|
if os.path.exists(UNPACKDIR):
|
|
shutil.rmtree(UNPACKDIR)
|
|
|
|
return ret
|
|
|
|
def _maintenance_hack(self, dst_project, dst_srcinfo, myrepos):
|
|
pkg = dst_srcinfo.package
|
|
originproject = None
|
|
originpackage = None
|
|
|
|
# find the maintenance project
|
|
url = osc.core.makeurl(self.apiurl, ('search', 'project', 'id'),
|
|
"match=(maintenance/maintains/@project='%s'+and+attribute/@name='%s')"%(dst_project, osc.conf.config['maintenance_attribute']))
|
|
root = ET.parse(osc.core.http_GET(url)).getroot()
|
|
if root is not None:
|
|
node = root.find('project')
|
|
if node is not None:
|
|
# check if target project is a project link where the
|
|
# sources don't actually build (like openSUSE:...:Update). That
|
|
# is the case if no update was released yet.
|
|
# XXX: TODO: do check for whether the package builds here first
|
|
originproject = self.get_originproject(dst_project, pkg)
|
|
if originproject is not None:
|
|
self.logger.debug("origin project %s", originproject)
|
|
url = osc.core.makeurl(self.apiurl, ('build', dst_project, '_result'), { 'package': pkg })
|
|
root = ET.parse(osc.core.http_GET(url)).getroot()
|
|
alldisabled = True
|
|
for node in root.findall('status'):
|
|
if node.get('code') != 'disabled':
|
|
alldisabled = False
|
|
if alldisabled:
|
|
self.logger.debug("all repos disabled, using originproject %s"%originproject)
|
|
else:
|
|
originproject = None
|
|
else:
|
|
mproject = node.attrib['name']
|
|
# packages are only a link to packagename.incidentnr
|
|
(linkprj, linkpkg) = self._get_linktarget(dst_project, pkg)
|
|
if linkpkg is not None and linkprj == dst_project:
|
|
self.logger.debug("%s/%s links to %s"%(dst_project, pkg, linkpkg))
|
|
regex = re.compile(r'.*\.(\d+)$')
|
|
m = regex.match(linkpkg)
|
|
if m is None:
|
|
raise MaintenanceError("%s/%s -> %s/%s is not a proper maintenance link (must match /%s/)"%(dst_project, pkg, linkprj, linkpkg, regex.pattern))
|
|
incident = m.group(1)
|
|
self.logger.debug("is maintenance incident %s"%incident)
|
|
|
|
originproject = "%s:%s"%(mproject, incident)
|
|
originpackage = pkg+'.'+dst_project.replace(':', '_')
|
|
|
|
origin_srcinfo = self.get_sourceinfo(originproject, originpackage)
|
|
if origin_srcinfo is None:
|
|
raise MaintenanceError("%s/%s invalid"%(originproject, originpackage))
|
|
|
|
# find the map of maintenance incident repos to destination repos
|
|
originrepos = self.findrepos(originproject, origin_srcinfo, dst_project, dst_srcinfo)
|
|
mapped = dict()
|
|
for mr in originrepos:
|
|
mapped[(mr.dstrepo, mr.arch)] = mr
|
|
|
|
self.logger.debug("mapping: %s", pformat(mapped))
|
|
|
|
# map the repos of the original request to the maintenance incident repos
|
|
matchrepos = set()
|
|
for mr in myrepos:
|
|
if not (mr.dstrepo, mr.arch) in mapped:
|
|
# sometimes a previously released maintenance
|
|
# update didn't cover all architectures. We can
|
|
# only ignore that then.
|
|
self.logger.warn("couldn't find repo %s/%s in %s/%s"%(mr.dstrepo, mr.arch, originproject, originpackage))
|
|
continue
|
|
matchrepos.add(MR(mr.srcrepo, mapped[(mr.dstrepo, mr.arch)].srcrepo, mr.arch))
|
|
|
|
myrepos = matchrepos
|
|
dst_srcinfo = origin_srcinfo
|
|
self.logger.debug("new repo map: %s", pformat(myrepos))
|
|
|
|
return (originproject, originpackage, dst_srcinfo, myrepos)
|
|
|
|
|
|
def find_abichecker_comment(self, req):
|
|
"""Return previous comments (should be one)."""
|
|
comments = self.commentapi.get_comments(request_id=req.reqid)
|
|
for c in comments.values():
|
|
m = comment_marker_re.match(c['comment'])
|
|
if m:
|
|
return c['id'], m.group('state'), m.group('result')
|
|
return None, None, None
|
|
|
|
def check_one_request(self, req):
|
|
|
|
self.review_messages = ReviewBot.ReviewBot.DEFAULT_REVIEW_MESSAGES
|
|
|
|
if self.no_review and not self.force and self.check_request_already_done(req.reqid):
|
|
self.logger.info("skip request %s which is already done", req.reqid)
|
|
# TODO: check if the request was seen before and we
|
|
# didn't reach a final state for too long
|
|
return None
|
|
|
|
commentid, state, result = self.find_abichecker_comment(req)
|
|
## using comments instead of db would be an options for bots
|
|
## that use no db
|
|
# if self.no_review:
|
|
# if state == 'done':
|
|
# self.logger.debug("request %s already done, result: %s"%(req.reqid, result))
|
|
# return
|
|
|
|
self.dblogger.request_id = req.reqid
|
|
|
|
self.reports = []
|
|
self.text_summary = ''
|
|
try:
|
|
ret = ReviewBot.ReviewBot.check_one_request(self, req)
|
|
except Exception as e:
|
|
import traceback
|
|
self.logger.error("unhandled exception in ABI checker")
|
|
self.logger.error(traceback.format_exc())
|
|
ret = None
|
|
|
|
result = None
|
|
if ret is not None:
|
|
state = 'done'
|
|
result = 'accepted' if ret else 'declined'
|
|
else:
|
|
# we probably don't want abichecker to spam here
|
|
# FIXME don't delete comment in this case
|
|
#if state is None and not self.text_summary:
|
|
# self.text_summary = 'abichecker will take a look later'
|
|
state = 'seen'
|
|
|
|
self.save_reports_to_db(req, state, result)
|
|
if ret is not None and self.text_summary == '':
|
|
# if for some reason save_reports_to_db didn't produce a
|
|
# summary we add one
|
|
self.text_summary = "ABI checker result: [%s](%s/request/%s)"%(result, WEB_URL, req.reqid)
|
|
|
|
if commentid and not self.dryrun:
|
|
self.commentapi.delete(commentid)
|
|
|
|
self.post_comment(req, state, result)
|
|
|
|
self.review_messages = { 'accepted': self.text_summary, 'declined': self.text_summary }
|
|
|
|
if self.no_review:
|
|
ret = None
|
|
|
|
self.dblogger.request_id = None
|
|
|
|
return ret
|
|
|
|
def check_request_already_done(self, reqid):
|
|
try:
|
|
request = self.session.query(DB.Request).filter(DB.Request.id == reqid).one()
|
|
if request.state == 'done':
|
|
return True
|
|
except sqlalchemy.orm.exc.NoResultFound as e:
|
|
pass
|
|
|
|
return False
|
|
|
|
def save_reports_to_db(self, req, state, result):
|
|
try:
|
|
request = self.session.query(DB.Request).filter(DB.Request.id == req.reqid).one()
|
|
for i in self.session.query(DB.ABICheck).filter(DB.ABICheck.request_id == request.id).all():
|
|
# yeah, we could be smarter here and update existing reports instead
|
|
self.session.delete(i)
|
|
self.session.flush()
|
|
request.state = state
|
|
request.result = result
|
|
except sqlalchemy.orm.exc.NoResultFound as e:
|
|
request = DB.Request(id = req.reqid,
|
|
state = state,
|
|
result = result,
|
|
)
|
|
self.session.add(request)
|
|
self.session.commit()
|
|
for r in self.reports:
|
|
abicheck = DB.ABICheck(
|
|
request = request,
|
|
src_project = r.src_project,
|
|
src_package = r.src_package,
|
|
src_rev = r.src_rev,
|
|
dst_project = r.dst_project,
|
|
dst_package = r.dst_package,
|
|
result = r.result
|
|
)
|
|
self.session.add(abicheck)
|
|
self.session.commit()
|
|
if r.result is None:
|
|
continue
|
|
elif r.result:
|
|
self.text_summary += "Good news from ABI check, "
|
|
self.text_summary += "%s seems to be ABI [compatible](%s/request/%s):\n\n"%(r.dst_package, WEB_URL, req.reqid)
|
|
else:
|
|
self.text_summary += "Warning: bad news from ABI check, "
|
|
self.text_summary += "%s may be ABI [**INCOMPATIBLE**](%s/request/%s):\n\n"%(r.dst_package, WEB_URL, req.reqid)
|
|
for lr in r.reports:
|
|
libreport = DB.LibReport(
|
|
abicheck = abicheck,
|
|
src_repo = lr.src_repo,
|
|
src_lib = lr.src_lib,
|
|
dst_repo = lr.dst_repo,
|
|
dst_lib = lr.dst_lib,
|
|
arch = lr.arch,
|
|
htmlreport = lr.htmlreport,
|
|
result = lr.result,
|
|
)
|
|
self.session.add(libreport)
|
|
self.session.commit()
|
|
self.text_summary += "* %s (%s): [%s](%s/report/%d)\n"%(lr.dst_lib, lr.arch,
|
|
"compatible" if lr.result else "***INCOMPATIBLE***",
|
|
WEB_URL, libreport.id)
|
|
|
|
self.reports = []
|
|
|
|
def post_comment(self, req, state, result):
|
|
if not self.text_summary:
|
|
return
|
|
|
|
msg = "<!-- abichecker state=%s%s -->\n"%(state, ' result=%s'%result if result else '')
|
|
msg += self.text_summary
|
|
|
|
self.logger.info("add comment: %s"%msg)
|
|
if not self.dryrun:
|
|
#self.commentapi.delete_from_where_user(self.review_user, request_id = req.reqid)
|
|
self.commentapi.add_comment(request_id = req.reqid, comment = msg)
|
|
|
|
def run_abi_checker(self, libname, old, new, output):
|
|
cmd = ['abi-compliance-checker',
|
|
'-lib', libname,
|
|
'-old', old,
|
|
'-new', new,
|
|
'-report-path', output
|
|
]
|
|
self.logger.debug(cmd)
|
|
r = subprocess.Popen(cmd, close_fds=True, cwd=CACHEDIR).wait()
|
|
if not r in (0, 1):
|
|
self.logger.error('abi-compliance-checker failed')
|
|
# XXX: record error
|
|
return None
|
|
return r == 0
|
|
|
|
def run_abi_dumper(self, output, base, filename):
|
|
cmd = ['abi-dumper',
|
|
'-o', output,
|
|
'-lver', os.path.basename(filename),
|
|
'/'.join([base, filename])]
|
|
debuglib = '%s/usr/lib/debug/%s.debug'%(base, filename)
|
|
if os.path.exists(debuglib):
|
|
cmd.append(debuglib)
|
|
self.logger.debug(cmd)
|
|
r = subprocess.Popen(cmd, close_fds=True, cwd=CACHEDIR).wait()
|
|
if r != 0:
|
|
self.logger.error("failed to dump %s!"%filename)
|
|
# XXX: record error
|
|
return False
|
|
return True
|
|
|
|
def extract(self, project, package, srcinfo, repo, arch):
|
|
# fetch cpio headers
|
|
# check file lists for library packages
|
|
fetchlist, liblist = self.compute_fetchlist(project, package, srcinfo, repo, arch)
|
|
|
|
if not fetchlist:
|
|
msg = "no libraries found in %s/%s %s/%s"%(project, package, repo, arch)
|
|
self.logger.info(msg)
|
|
return None
|
|
|
|
# mtimes in cpio are not the original ones, so we need to fetch
|
|
# that separately :-(
|
|
mtimes= self._getmtimes(project, package, repo, arch)
|
|
|
|
self.logger.debug("fetchlist %s", pformat(fetchlist))
|
|
self.logger.debug("liblist %s", pformat(liblist))
|
|
|
|
debugfiles = set(['/usr/lib/debug%s.debug'%f for f in liblist])
|
|
|
|
# fetch binary rpms
|
|
downloaded = self.download_files(project, package, repo, arch, fetchlist, mtimes)
|
|
|
|
# extract binary rpms
|
|
tmpfile = os.path.join(CACHEDIR, "cpio")
|
|
for fn in fetchlist:
|
|
self.logger.debug("extract %s"%fn)
|
|
with open(tmpfile, 'wb') as tmpfd:
|
|
if not fn in downloaded:
|
|
raise FetchError("%s was not downloaded!"%fn)
|
|
self.logger.debug(downloaded[fn])
|
|
r = subprocess.call(['rpm2cpio', downloaded[fn]], stdout=tmpfd, close_fds=True)
|
|
if r != 0:
|
|
raise FetchError("failed to extract %s!"%fn)
|
|
tmpfd.close()
|
|
cpio = CpioRead(tmpfile)
|
|
cpio.read()
|
|
for ch in cpio:
|
|
fn = ch.filename
|
|
if fn.startswith('./'): # rpm payload is relative
|
|
fn = fn[1:]
|
|
self.logger.debug("cpio fn %s", fn)
|
|
if not fn in liblist and not fn in debugfiles:
|
|
continue
|
|
dst = os.path.join(UNPACKDIR, project, package, repo, arch)
|
|
dst += fn
|
|
if not os.path.exists(os.path.dirname(dst)):
|
|
os.makedirs(os.path.dirname(dst))
|
|
self.logger.debug("dst %s", dst)
|
|
# the filehandle in the cpio archive is private so
|
|
# open it again
|
|
with open(tmpfile, 'rb') as cpiofh:
|
|
cpiofh.seek(ch.dataoff, os.SEEK_SET)
|
|
with open(dst, 'wb') as fh:
|
|
while True:
|
|
buf = cpiofh.read(4096)
|
|
if buf is None or buf == '':
|
|
break
|
|
fh.write(buf)
|
|
os.unlink(tmpfile)
|
|
|
|
return liblist
|
|
|
|
def download_files(self, project, package, repo, arch, filenames, mtimes):
|
|
downloaded = dict()
|
|
for fn in filenames:
|
|
if not fn in mtimes:
|
|
raise FetchError("missing mtime information for %s, can't check"% fn)
|
|
repodir = os.path.join(DOWNLOADS, package, project, repo)
|
|
if not os.path.exists(repodir):
|
|
os.makedirs(repodir)
|
|
t = os.path.join(repodir, fn)
|
|
self._get_binary_file(project, repo, arch, package, fn, t, mtimes[fn])
|
|
downloaded[fn] = t
|
|
return downloaded
|
|
|
|
# XXX: from repochecker
|
|
def _get_binary_file(self, project, repository, arch, package, filename, target, mtime):
|
|
"""Get a binary file from OBS."""
|
|
# Check if the file is already there.
|
|
key = (project, repository, arch, package, filename, mtime)
|
|
if key in self.pkgcache:
|
|
try:
|
|
os.unlink(target)
|
|
except:
|
|
pass
|
|
self.pkgcache.linkto(key, target)
|
|
else:
|
|
osc.core.get_binary_file(self.apiurl, project, repository, arch,
|
|
filename, package=package,
|
|
target_filename=target)
|
|
self.pkgcache[key] = target
|
|
|
|
def readRpmHeaderFD(self, fd):
|
|
h = None
|
|
try:
|
|
h = self.ts.hdrFromFdno(fd)
|
|
except rpm.error as e:
|
|
if str(e) == "public key not available":
|
|
print str(e)
|
|
if str(e) == "public key not trusted":
|
|
print str(e)
|
|
if str(e) == "error reading package header":
|
|
print str(e)
|
|
h = None
|
|
return h
|
|
|
|
def _fetchcpioheaders(self, project, package, repo, arch):
|
|
u = osc.core.makeurl(self.apiurl, [ 'build', project, repo, arch, package ],
|
|
[ 'view=cpioheaders' ])
|
|
try:
|
|
r = osc.core.http_GET(u)
|
|
except urllib2.HTTPError as e:
|
|
raise FetchError('failed to fetch header information: %s'%e)
|
|
tmpfile = NamedTemporaryFile(prefix="cpio-", delete=False)
|
|
for chunk in r:
|
|
tmpfile.write(chunk)
|
|
tmpfile.close()
|
|
cpio = CpioRead(tmpfile.name)
|
|
cpio.read()
|
|
rpm_re = re.compile('(.+\.rpm)-[0-9A-Fa-f]{32}$')
|
|
for ch in cpio:
|
|
# ignore errors
|
|
if ch.filename == '.errors':
|
|
continue
|
|
# the filehandle in the cpio archive is private so
|
|
# open it again
|
|
with open(tmpfile.name, 'rb') as fh:
|
|
fh.seek(ch.dataoff, os.SEEK_SET)
|
|
h = self.readRpmHeaderFD(fh)
|
|
if h is None:
|
|
raise FetchError("failed to read rpm header for %s"%ch.filename)
|
|
m = rpm_re.match(ch.filename)
|
|
if m:
|
|
yield m.group(1), h
|
|
os.unlink(tmpfile.name)
|
|
|
|
def _getmtimes(self, prj, pkg, repo, arch):
|
|
""" returns a dict of filename: mtime """
|
|
url = osc.core.makeurl(self.apiurl, ('build', prj, repo, arch, pkg))
|
|
try:
|
|
root = ET.parse(osc.core.http_GET(url)).getroot()
|
|
except urllib2.HTTPError:
|
|
return None
|
|
|
|
return dict([(node.attrib['filename'], node.attrib['mtime']) for node in root.findall('binary')])
|
|
|
|
# modified from repochecker
|
|
def _last_build_success(self, src_project, tgt_project, src_package, rev):
|
|
"""Return the last build success XML document from OBS."""
|
|
try:
|
|
query = { 'lastsuccess' : 1,
|
|
'package' : src_package,
|
|
'pathproject' : tgt_project,
|
|
'srcmd5' : rev }
|
|
url = osc.core.makeurl(self.apiurl, ('build', src_project, '_result'), query)
|
|
return ET.parse(osc.core.http_GET(url)).getroot()
|
|
except urllib2.HTTPError as e:
|
|
if e.code != 404:
|
|
self.logger.error('ERROR in URL %s [%s]' % (url, e))
|
|
raise
|
|
pass
|
|
return None
|
|
|
|
def get_buildsuccess_repos(self, src_project, tgt_project, src_package, rev):
|
|
root = self._last_build_success(src_project, tgt_project, src_package, rev)
|
|
if root is None:
|
|
return None
|
|
|
|
# build list of repos as set of (name, arch) tuples
|
|
repos = set()
|
|
for repo in root.findall('repository'):
|
|
name = repo.attrib['name']
|
|
for node in repo.findall('arch'):
|
|
repos.add((name, node.attrib['arch']))
|
|
|
|
self.logger.debug("success repos: %s", pformat(repos))
|
|
|
|
return repos
|
|
|
|
def get_dstrepos(self, project):
|
|
url = osc.core.makeurl(self.apiurl, ('source', project, '_meta'))
|
|
try:
|
|
root = ET.parse(osc.core.http_GET(url)).getroot()
|
|
except urllib2.HTTPError:
|
|
return None
|
|
|
|
repos = set()
|
|
for repo in root.findall('repository'):
|
|
name = repo.attrib['name']
|
|
if project in REPO_WHITELIST and name not in REPO_WHITELIST[project]:
|
|
continue
|
|
|
|
for node in repo.findall('arch'):
|
|
arch = node.text
|
|
|
|
if project in ARCH_WHITELIST and arch not in ARCH_WHITELIST[project]:
|
|
continue
|
|
|
|
repos.add((name, arch))
|
|
|
|
return repos
|
|
|
|
def ensure_settled(self, src_project, src_srcinfo, matchrepos):
|
|
""" make sure current build state is final so we're not
|
|
tricked with half finished results"""
|
|
rmap = dict()
|
|
results = osc.core.get_package_results(self.apiurl,
|
|
src_project, src_srcinfo.package,
|
|
repository = [ mr.srcrepo for mr in matchrepos],
|
|
arch = [ mr.arch for mr in matchrepos])
|
|
for result in results:
|
|
for res in osc.core.result_xml_to_dicts(result):
|
|
if not 'package' in res or res['package'] != src_srcinfo.package:
|
|
continue
|
|
rmap[(res['repository'], res['arch'])] = res
|
|
|
|
for mr in matchrepos:
|
|
if not (mr.srcrepo, mr.arch) in rmap:
|
|
self.logger.warn("%s/%s had no build success"%(mr.srcrepo, mr.arch))
|
|
raise NotReadyYet(src_project, src_srcinfo.package, "no result")
|
|
if rmap[(mr.srcrepo, mr.arch)]['dirty']:
|
|
self.logger.warn("%s/%s dirty"%(mr.srcrepo, mr.arch))
|
|
raise NotReadyYet(src_project, src_srcinfo.package, "dirty")
|
|
code = rmap[(mr.srcrepo, mr.arch)]['code']
|
|
if code == 'broken':
|
|
raise SourceBroken(src_project, src_srcinfo.package)
|
|
if code != 'succeeded' and code != 'locked' and code != 'excluded':
|
|
self.logger.warn("%s/%s not succeeded (%s)"%(mr.srcrepo, mr.arch, code))
|
|
raise NotReadyYet(src_project, src_srcinfo.package, code)
|
|
|
|
def findrepos(self, src_project, src_srcinfo, dst_project, dst_srcinfo):
|
|
|
|
# get target repos that had a successful build
|
|
dstrepos = self.get_dstrepos(dst_project)
|
|
if dstrepos is None:
|
|
return None
|
|
|
|
url = osc.core.makeurl(self.apiurl, ('source', src_project, '_meta'))
|
|
try:
|
|
root = ET.parse(osc.core.http_GET(url)).getroot()
|
|
except urllib2.HTTPError:
|
|
return None
|
|
|
|
# set of source repo name, target repo name, arch
|
|
matchrepos = set()
|
|
for repo in root.findall('repository'):
|
|
name = repo.attrib['name']
|
|
path = repo.findall('path')
|
|
if path is None or len(path) != 1:
|
|
self.logger.error("repo %s has more than one path"%name)
|
|
continue
|
|
prj = path[0].attrib['project']
|
|
if prj == 'openSUSE:Tumbleweed':
|
|
prj = 'openSUSE:Factory' # XXX: hack
|
|
if prj != dst_project:
|
|
continue
|
|
for node in repo.findall('arch'):
|
|
arch = node.text
|
|
dstname = path[0].attrib['repository']
|
|
if (dstname, arch) in dstrepos:
|
|
matchrepos.add(MR(name, dstname, arch))
|
|
|
|
if not matchrepos:
|
|
return None
|
|
else:
|
|
self.logger.debug('matched repos %s', pformat(matchrepos))
|
|
|
|
# make sure it's not dirty
|
|
self.ensure_settled(src_project, src_srcinfo, matchrepos)
|
|
|
|
# now check if all matched repos built successfully
|
|
srcrepos = self.get_buildsuccess_repos(src_project, dst_project, src_srcinfo.package, src_srcinfo.verifymd5)
|
|
if srcrepos is None:
|
|
raise NotReadyYet(src_project, src_srcinfo.package, "no build success")
|
|
if not srcrepos:
|
|
raise NoBuildSuccess(src_project, src_srcinfo.package, src_srcinfo.verifymd5)
|
|
for mr in matchrepos:
|
|
if not (mr.srcrepo, arch) in srcrepos:
|
|
self.logger.error("%s/%s had no build success"%(mr.srcrepo, arch))
|
|
raise NoBuildSuccess(src_project, src_srcinfo.package, src_srcinfo.verifymd5)
|
|
|
|
return matchrepos
|
|
|
|
# common with repochecker
|
|
def _md5_disturl(self, disturl):
|
|
"""Get the md5 from the DISTURL from a RPM file."""
|
|
return os.path.basename(disturl).split('-')[0]
|
|
|
|
def disturl_matches_md5(self, disturl, md5):
|
|
if self._md5_disturl(disturl) != md5:
|
|
return False
|
|
return True
|
|
|
|
# this is a bit magic. OBS allows to take the disturl md5 from the package
|
|
# and query the source info for that. We will then get the verify md5 that
|
|
# belongs to that md5.
|
|
def disturl_matches(self, disturl, prj, srcinfo):
|
|
md5 = self._md5_disturl(disturl)
|
|
info = self.get_sourceinfo(prj, srcinfo.package, rev = md5)
|
|
self.logger.debug(pformat(srcinfo))
|
|
self.logger.debug(pformat(info))
|
|
if info.verifymd5 == srcinfo.verifymd5:
|
|
return True
|
|
return False
|
|
|
|
def compute_fetchlist(self, prj, pkg, srcinfo, repo, arch):
|
|
""" scan binary rpms of the specified repo for libraries.
|
|
Returns a set of packages to fetch and the libraries found
|
|
"""
|
|
self.logger.debug('scanning %s/%s %s/%s'%(prj, pkg, repo, arch))
|
|
|
|
headers = self._fetchcpioheaders(prj, pkg, repo, arch)
|
|
missing_debuginfo = set()
|
|
lib_packages = dict() # pkgname -> set(lib file names)
|
|
pkgs = dict() # pkgname -> cpiohdr, rpmhdr
|
|
lib_aliases = dict()
|
|
for rpmfn, h in headers:
|
|
# skip src rpm
|
|
if h['sourcepackage']:
|
|
continue
|
|
pkgname = h['name']
|
|
if pkgname.endswith('-32bit') or pkgname.endswith('-64bit'):
|
|
# -32bit and -64bit packages are just repackaged, so
|
|
# we skip them and only check the original one.
|
|
continue
|
|
self.logger.debug(pkgname)
|
|
if not self.disturl_matches(h['disturl'], prj, srcinfo):
|
|
raise DistUrlMismatch(h['disturl'], srcinfo)
|
|
pkgs[pkgname] = (rpmfn, h)
|
|
if debugpkg_re.match(pkgname):
|
|
continue
|
|
for fn, mode, lnk in zip(h['filenames'], h['filemodes'], h['filelinktos']):
|
|
if so_re.match(fn):
|
|
if S_ISREG(mode):
|
|
self.logger.debug('found lib: %s'%fn)
|
|
lib_packages.setdefault(pkgname, set()).add(fn)
|
|
elif S_ISLNK(mode) and lnk is not None:
|
|
alias = os.path.basename(fn)
|
|
libname = os.path.basename(lnk)
|
|
self.logger.debug('found alias: %s -> %s'%(alias, libname))
|
|
lib_aliases.setdefault(libname, set()).add(alias)
|
|
|
|
fetchlist = set()
|
|
liblist = dict()
|
|
# check whether debug info exists for each lib
|
|
for pkgname in sorted(lib_packages.keys()):
|
|
dpkgname = pkgname+'-debuginfo'
|
|
if not dpkgname in pkgs:
|
|
missing_debuginfo.add((prj, pkg, repo, arch, pkgname))
|
|
continue
|
|
|
|
# check file list of debuginfo package
|
|
rpmfn, h = pkgs[dpkgname]
|
|
files = set (h['filenames'])
|
|
ok = True
|
|
for lib in lib_packages[pkgname]:
|
|
fn = '/usr/lib/debug%s.debug'%lib
|
|
if not fn in files:
|
|
missing_debuginfo.add((prj, pkg, repo, arch, pkgname, lib))
|
|
ok = False
|
|
if ok:
|
|
fetchlist.add(pkgs[pkgname][0])
|
|
fetchlist.add(rpmfn)
|
|
liblist.setdefault(lib, set())
|
|
libname = os.path.basename(lib)
|
|
if libname in lib_aliases:
|
|
liblist[lib] |= lib_aliases[libname]
|
|
|
|
if missing_debuginfo:
|
|
self.logger.error('missing debuginfo: %s'%pformat(missing_debuginfo))
|
|
raise MissingDebugInfo(missing_debuginfo)
|
|
|
|
return fetchlist, liblist
|
|
|
|
class CommandLineInterface(ReviewBot.CommandLineInterface):
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
ReviewBot.CommandLineInterface.__init__(self, args, kwargs)
|
|
self.clazz = ABIChecker
|
|
|
|
def get_optparser(self):
|
|
parser = ReviewBot.CommandLineInterface.get_optparser(self)
|
|
parser.add_option("--force", action="store_true", help="recheck requests that are already considered done")
|
|
parser.add_option("--no-review", action="store_true", help="don't actually accept or decline, just comment")
|
|
parser.add_option("--web-url", metavar="URL", help="URL of web service")
|
|
return parser
|
|
|
|
def postoptparse(self):
|
|
ret = ReviewBot.CommandLineInterface.postoptparse(self)
|
|
if self.options.web_url is not None:
|
|
global WEB_URL
|
|
WEB_URL = self.options.web_url
|
|
else:
|
|
self.optparser.error("must specify --web-url")
|
|
ret = False
|
|
return ret
|
|
|
|
def setup_checker(self):
|
|
bot = ReviewBot.CommandLineInterface.setup_checker(self)
|
|
|
|
if self.options.no_review:
|
|
bot.no_review = True
|
|
if self.options.force:
|
|
bot.force = True
|
|
|
|
return bot
|
|
|
|
@cmdln.option('-r', '--revision', metavar="number", type="int", help="revision number")
|
|
def do_diff(self, subcmd, opts, src_project, src_package, dst_project, dst_package):
|
|
src_rev = opts.revision
|
|
print self.checker.check_source_submission(src_project, src_package, src_rev, dst_project, dst_package)
|
|
|
|
if __name__ == "__main__":
|
|
app = CommandLineInterface()
|
|
sys.exit( app.main() )
|
|
|