1076 lines
43 KiB
Python
Executable File
1076 lines
43 KiB
Python
Executable File
#!/usr/bin/python
|
|
# Copyright (c) 2015 SUSE Linux GmbH
|
|
#
|
|
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
# of this software and associated documentation files (the "Software"), to deal
|
|
# in the Software without restriction, including without limitation the rights
|
|
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
# copies of the Software, and to permit persons to whom the Software is
|
|
# furnished to do so, subject to the following conditions:
|
|
#
|
|
# The above copyright notice and this permission notice shall be included in
|
|
# all copies or substantial portions of the Software.
|
|
#
|
|
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
# SOFTWARE.
|
|
|
|
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'))
|
|
|
|
# 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 = 'debug information is missing for the following files, can\'t check:\n'
|
|
for i in missing_debuginfo:
|
|
self.msg += "%s/%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):
|
|
self.no_review = False
|
|
self.force = False
|
|
if 'no_review' in kwargs:
|
|
if kwargs['no_review'] == True:
|
|
self.no_review = True
|
|
del kwargs['no_review']
|
|
if 'force' in kwargs:
|
|
if kwargs['force'] == True:
|
|
self.force = True
|
|
del kwargs['force']
|
|
|
|
ReviewBot.ReviewBot.__init__(self, *args, **kwargs)
|
|
|
|
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
|
|
# 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, e:
|
|
self.logger.info(e)
|
|
self.reports.append(report)
|
|
return False
|
|
except NotReadyYet, e:
|
|
self.logger.info(e)
|
|
self.reports.append(report)
|
|
return None
|
|
except SourceBroken, 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, e:
|
|
self.text_summary += "**Error**: %s\n\n"%e
|
|
self.logger.error('%s', e)
|
|
self.reports.append(report)
|
|
return False
|
|
|
|
notes = []
|
|
libresults = []
|
|
|
|
overall = None
|
|
|
|
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, 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, e:
|
|
self.text_summary += str(e) + "\n"
|
|
ret = False
|
|
continue
|
|
except FetchError, 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, 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, e:
|
|
self.text_summary += str(e) + "\n"
|
|
ret = False
|
|
continue
|
|
except FetchError, 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():
|
|
return
|
|
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()
|
|
|
|
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))
|
|
m = re.match(r'.*\.(\d+)$', linkpkg)
|
|
if m is None:
|
|
raise MaintenanceError("%s/%s is not a proper maintenance link %s"%(dst_project, pkg))
|
|
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:
|
|
raise MaintenanceError("couldn't find repo %s/%s in %s/%s"(mr.dstrepo, mr.arch, originproject, originpackage))
|
|
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, 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'
|
|
if self.text_summary == '':
|
|
self.text_summary = "ABI checker result: %s"%result
|
|
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 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, 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, 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.call(cmd, close_fds=True)
|
|
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.call(cmd, close_fds=True)
|
|
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, 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, 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, e:
|
|
self.logger.error('ERROR in URL %s [%s]' % (url, e))
|
|
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"""
|
|
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])
|
|
rmap = dict()
|
|
for i in results:
|
|
if not 'package' in i or i['package'] != src_srcinfo.package:
|
|
continue
|
|
rmap[(i['repository'], i['arch'])] = i
|
|
|
|
for mr in matchrepos:
|
|
if not (mr.srcrepo, mr.arch) in rmap:
|
|
self.logger.warn("%s/%s had no build success"%(mr.srcrepo, 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 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, None))
|
|
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
|
|
|
|
def set_request_ids_project(self, project, typename):
|
|
url = osc.core.makeurl(self.apiurl, ('search', 'request'),
|
|
"match=(state/@name='review'+or+state/@name='new')+and+(action/target/@project='%s'+and+action/@type='%s')&withhistory=1"%(project, typename))
|
|
root = ET.parse(osc.core.http_GET(url)).getroot()
|
|
|
|
self.requests = []
|
|
|
|
for request in root.findall('request'):
|
|
req = osc.core.Request()
|
|
req.read(request)
|
|
self.requests.append(req)
|
|
|
|
class CommandLineInterface(ReviewBot.CommandLineInterface):
|
|
|
|
def __init__(self, *args, **kwargs):
|
|
ReviewBot.CommandLineInterface.__init__(self, args, kwargs)
|
|
|
|
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):
|
|
|
|
apiurl = osc.conf.config['apiurl']
|
|
if apiurl is None:
|
|
raise osc.oscerr.ConfigError("missing apiurl")
|
|
user = self.options.user
|
|
if user is None:
|
|
user = osc.conf.get_apiurl_usr(apiurl)
|
|
|
|
return ABIChecker(apiurl = apiurl, \
|
|
dryrun = self.options.dry, \
|
|
no_review = self.options.no_review, \
|
|
user = user, \
|
|
force = self.options.force, \
|
|
logger = self.logger)
|
|
|
|
@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)
|
|
|
|
@cmdln.option('-n', '--interval', metavar="minutes", type="int", help="periodic interval in minutes")
|
|
def do_project(self, subcmd, opts, project, typename):
|
|
def work():
|
|
self.checker.set_request_ids_project(project, typename)
|
|
self.checker.check_requests()
|
|
|
|
self.runner(work, opts.interval)
|
|
|
|
if __name__ == "__main__":
|
|
app = CommandLineInterface()
|
|
sys.exit( app.main() )
|
|
|
|
# vim: sw=4 et
|