# # (C) 2011 coolo@suse.de, Novell Inc, openSUSE.org # Distribute under GPLv2 or GPLv3 # # Copy this script to ~/.osc-plugins/ or /var/lib/osc-plugins . # Then try to run 'osc check_repo --help' to see the usage. import cPickle from datetime import datetime from functools import wraps import os import shelve import re import subprocess import shutil from urllib import quote_plus import urllib2 from xml.etree import cElementTree as ET from osc import oscerr from osc import cmdln from osc.core import get_binary_file from osc.core import get_buildinfo from osc.core import http_GET from osc.core import http_POST from osc.core import makeurl from osc.core import Request # # Ugly hack -- because the way that osc import plugings we need to # declase some functions and objects used in the decorator as global # global cPickle global datetime global shelve global wraps global memoize global last_build_success def memoize(f): """Decorator function to implement a persistent cache. >>> @memoize ... def test_func(a): ... return a Internally, the memoized function has a cache: >>> cache = [c.cell_contents for c in test_func.func_closure if 'sync' in dir(c.cell_contents)][0] >>> 'sync' in dir(cache) True There is a limit of the size of the cache >>> for k in cache: ... del cache[k] >>> len(cache) 0 >>> for i in range(4095): ... test_func(i) ... len(cache) 4095 >>> test_func(0) 0 >>> len(cache) 4095 >>> test_func(4095) 4095 >>> len(cache) 3072 >>> test_func(0) 0 >>> len(cache) 3073 >>> from datetime import timedelta >>> k = [k for k in cache if cPickle.loads(k) == ((0,), {})][0] >>> t, v = cache[k] >>> t = t - timedelta(days=10) >>> cache[k] = (t, v) >>> test_func(0) 0 >>> t2, v = cache[k] >>> t != t2 True """ # Configuration variables TMPDIR = '/tmp' # Where the cache files are stored SLOTS = 4096 # Number of slots in the cache file NCLEAN = 1024 # Number of slots to remove when limit reached TIMEOUT = 60*60*2 # Time to live for every cache slot (seconds) def _clean_cache(): len_cache = len(cache) if len_cache >= SLOTS: nclean = NCLEAN + len_cache - SLOTS keys_to_delete = sorted(cache, key=lambda k: cache[k][0])[:nclean] for key in keys_to_delete: del cache[key] @wraps(f) def _f(*args, **kwargs): now = datetime.now() key = cPickle.dumps((args, kwargs), protocol=-1) updated = False if key in cache: timestamp, value = cache[key] updated = True if (now-timestamp).total_seconds() < TIMEOUT else False if not updated: value = f(*args, **kwargs) cache[key] = (now, value) _clean_cache() return value cache_name = os.path.join(TMPDIR, f.__name__) cache = shelve.open(cache_name, protocol=-1) return _f def _check_repo_change_review_state(self, opts, id_, newstate, message='', supersed=None): """Taken from osc/osc/core.py, improved: - verbose option added, - empty by_user=& removed. - numeric id can be int(). """ query = { 'cmd': 'changereviewstate', 'newstate': newstate, 'by_user': 'factory-repo-checker', } if supersed: query['superseded_by'] = supersed # if message: # query['comment'] = message code = 404 u = makeurl(opts.apiurl, ['request', str(id_)], query=query) try: f = http_POST(u, data=message) root = ET.parse(f).getroot() code = root.attrib['code'] except urllib2.HTTPError, e: print 'ERROR in URL %s [%s]'%(u, e) return code def _check_repo_find_submit_request(self, opts, project, package): xpath = "(action/target/@project='%s' and action/target/@package='%s' and action/@type='submit' and (state/@name='new' or state/@name='review' or state/@name='accepted'))" % (project, package) try: url = makeurl(opts.apiurl, ['search','request'], 'match=%s' % quote_plus(xpath)) f = http_GET(url) collection = ET.parse(f).getroot() except urllib2.HTTPError, e: print 'ERROR in URL %s [%s]'%(url, e) return None for root in collection.findall('request'): r = Request() r.read(root) return int(r.reqid) return None def _check_repo_fetch_group(self, opts, group): if group in opts.groups: return url = makeurl(opts.apiurl, ['request', str(group)]) root = ET.parse(http_GET(url)).getroot() # Every opts.groups[group_id] will contains the list of ids that # conform the group groups = [int(req.attrib['id']) for req in root.find('action').findall('grouped')] opts.groups[group] = groups # opts.grouped[id] will point to the group_id which belongs to grouped = {id_: group for id_ in groups} opts.grouped.update(grouped) def _check_repo_avoid_wrong_friends(self, prj, repo, arch, pkg, opts): try: url = makeurl(opts.apiurl, ["build", prj, repo, arch, pkg]) root = ET.parse(http_GET(url)).getroot() except urllib2.HTTPError, e: print 'ERROR in URL %s [%s]'%(url, e) return False for binary in root.findall('binary'): # if there are binaries, we're out return False return True def _check_repo_one_request(self, rq, opts): class CheckRepoPackage: def __repr__(self): return '[%d:%s/%s]' % (int(self.request), self.sproject, self.spackage) def __init__(self): self.updated = False self.error = None self.build_excluded = False id_ = int(rq.get('id')) actions = rq.findall('action') if len(actions) > 1: msg = 'only one action per request is supported - create a group instead: '\ 'https://github.com/SUSE/hackweek/wiki/Improved-Factory-devel-project-submission-workflow' print 'DECLINED', msg self._check_repo_change_review_state(opts, id_, 'declined', message=msg) return [] act = actions[0] type_ = act.get('type') if type_ != 'submit': self._check_repo_change_review_state(opts, id_, 'accepted', message='Unchecked request type %s'%type_) return [] pkg = act.find('source').get('package') prj = act.find('source').get('project') rev = act.find('source').get('rev') tprj = act.find('target').get('project') tpkg = act.find('target').get('package') subm_id = 'SUBMIT(%d):' % id_ print '%s %s/%s -> %s/%s' % (subm_id, prj, pkg, tprj, tpkg) group = id_ try: if id_ in opts.grouped: group = opts.grouped[id_] else: # Search in which group this id_ is included. The result # in an XML document pointing to a single submit request # ID if this id_ is actually part of a group url = makeurl(opts.apiurl, ['search', 'request', 'id?match=action/grouped/@id=%s'%id_]) root = ET.parse(http_GET(url)).getroot() reqs = root.findall('request') if reqs: group = int(reqs[0].attrib['id']) # Recover the full group description, with more SRIDs # and populate opts.group and opts.grouped self._check_repo_fetch_group(opts, group) except urllib2.HTTPError, e: print 'ERROR in URL %s [%s]'%(url, e) return [] packs = [] p = CheckRepoPackage() p.spackage = pkg p.sproject = prj p.tpackage = tpkg p.tproject = tprj p.group = group p.request = id_ # Get source information about the SR: # - Source MD5 # - Entries (.tar.gz, .changes, .spec ...) and MD5 try: url = makeurl(opts.apiurl, ['source', prj, pkg, '?expand=1&rev=%s'%rev]) root = ET.parse(http_GET(url)).getroot() except urllib2.HTTPError, e: print 'ERROR in URL %s [%s]'%(url, e) return [] p.rev = root.attrib['srcmd5'] # Recover the .spec files specs = [e.attrib['name'][:-5] for e in root.findall('entry') if e.attrib['name'].endswith('.spec')] # source checker validated it exists specs.remove(tpkg) packs.append(p) # Validate the rest of the spec files for spec in specs: lprj, lpkg, lmd5 = '', '', '' try: url = makeurl(opts.apiurl, ['source', prj, spec, '?expand=1']) root = ET.parse(http_GET(url)).getroot() link = root.find('linkinfo') if link != None: lprj = link.attrib.get('project', '') lpkg = link.attrib.get('package', '') lmd5 = link.attrib['srcmd5'] except urllib2.HTTPError: pass # leave lprj if lprj != prj or lpkg != pkg and not p.updated: msg = '%s/%s should _link to %s/%s' % (prj,spec,prj,pkg) self._check_repo_change_review_state(opts, id_, 'declined', message=msg) print msg p.updated = True if lmd5 != p.rev and not p.updated: msg = '%s/%s is a link but has a different md5sum than %s?' % (prj,spec,pkg) self._check_repo_change_review_state(opts, id_, 'new', message=msg) print msg p.updated = True sp = CheckRepoPackage() sp.spackage = spec sp.sproject = prj sp.tpackage = spec sp.tproject = tprj sp.group = p.group sp.request = id_ packs.append(sp) sp.rev = root.attrib['srcmd5'] return packs @memoize def last_build_success(apiurl, src_project, tgt_project, src_package, rev): root = None try: url = makeurl(apiurl, ['build', src_project, '_result?lastsuccess&package=%s&pathproject=%s&srcmd5=%s'%(quote_plus(src_package), quote_plus(tgt_project), rev)]) root = http_GET(url).read() except urllib2.HTTPError, e: print 'ERROR in URL %s [%s]'%(url, e) return root def _check_repo_buildsuccess(self, p, opts): root_xml = last_build_success(opts.apiurl, p.sproject, p.tproject, p.spackage, p.rev) root = ET.fromstring(root_xml) if not root: return False if 'code' in root.attrib: print ET.tostring(root) return False result = False p.goodrepo = None missings = {} alldisabled = True foundbuilding = None foundfailed = None tocheckrepos = [] for repo in root.findall('repository'): archs = [a.attrib['arch'] for a in repo.findall('arch')] foundarchs = len([a for a in archs if a in ('i586', 'x86_64')]) if foundarchs == 2: tocheckrepos.append(repo) if not tocheckrepos: msg = 'Missing i586 and x86_64 in the repo list' self._check_repo_change_review_state(opts, p.request, 'new', message=msg) print 'UPDATED', msg return False for repo in tocheckrepos: isgood = True founddisabled = False r_foundbuilding = None r_foundfailed = None r_missings = {} for arch in repo.findall('arch'): if arch.attrib['arch'] not in ('i586', 'x86_64'): continue if 'missing' in arch.attrib: for pkg in arch.attrib['missing'].split(','): if not self._check_repo_avoid_wrong_friends(p.sproject, repo.attrib['name'], arch.attrib['arch'], pkg, opts): missings[pkg] = 1 if not (arch.attrib['result'] in ['succeeded', 'excluded']): isgood = False if arch.attrib['result'] == 'excluded' and arch.attrib['arch'] == 'x86_64': p.build_excluded = True if arch.attrib['result'] == 'disabled': founddisabled = True if arch.attrib['result'] == 'failed': r_foundfailed = repo.attrib['name'] if arch.attrib['result'] == 'building': r_foundbuilding = repo.attrib['name'] if arch.attrib['result'] == 'outdated': msg = "%s's sources were changed after submissions and the old sources never built. Please resubmit" % p.spackage print 'DECLINED', msg self._check_repo_change_review_state(opts, p.request, 'new', message=msg) return False r_missings = r_missings.keys() for pkg in r_missings: missings[pkg] = 1 if not founddisabled: alldisabled = False if isgood: p.goodrepo = repo.attrib['name'] result = True if r_foundbuilding: foundbuilding = r_foundbuilding if r_foundfailed: foundfailed = r_foundfailed p.missings = sorted(missings) if result: return True if alldisabled: msg = "%s is disabled or does not build against factory. Please fix and resubmit" % p.spackage print 'DECLINED', msg self._check_repo_change_review_state(opts, p.request, 'declined', message=msg) return False if foundbuilding: msg = "{1} is still building for repository {0}".format(foundbuilding, p.spackage) self._check_repo_change_review_state(opts, p.request, 'new', message=msg) print 'UPDATED', msg return False if foundfailed: msg = "{1} failed to build in repository {0} - not accepting".format(foundfailed, p.spackage) self._check_repo_change_review_state(opts, p.request, 'new', message=msg) print 'UPDATED', msg return False return True def _check_repo_repo_list(self, prj, repo, arch, pkg, opts, ignore=False): url = makeurl(opts.apiurl, ['build', prj, repo, arch, pkg]) files = [] try: binaries = ET.parse(http_GET(url)).getroot() for bin_ in binaries.findall('binary'): fn = bin_.attrib['filename'] result = re.match("(.*)-([^-]*)-([^-]*)\.([^-\.]+)\.rpm", fn) if not result: if fn == 'rpmlint.log': files.append((fn, '', '')) continue pname = result.group(1) print 'PNAME', pname if pname.endswith('-debuginfo') or pname.endswith('-debuginfo-32bit'): continue if pname.endswith('-debugsource'): continue if result.group(4) == 'src': continue files.append((fn, pname, result.group(4))) except urllib2.HTTPError, e: if not ignore: print 'ERROR in URL %s [%s]'%(url, e) return files def _check_repo_get_binary(self, apiurl, prj, repo, arch, package, file, target): if os.path.exists(target): return get_binary_file(apiurl, prj, repo, arch, file, package = package, target_filename = target) def _check_repo_download(self, p, destdir, opts): if p.build_excluded: return [], [] p.destdir = destdir + "/%s" % p.tpackage if not os.path.isdir(p.destdir): os.makedirs(p.destdir, 0755) # we can assume x86_64 is there todownload = [] for fn in self._check_repo_repo_list(p.sproject, p.goodrepo, 'x86_64', p.spackage, opts): todownload.append(('x86_64', fn[0])) # now fetch -32bit packs for fn in self._check_repo_repo_list(p.sproject, p.goodrepo, 'i586', p.spackage, opts): if fn[2] != 'x86_64': continue todownload.append(('i586', fn[0])) downloads = [] for arch, fn in todownload: t = os.path.join(p.destdir, fn) self._check_repo_get_binary(opts.apiurl, p.sproject, p.goodrepo, arch, p.spackage, fn, t) downloads.append(t) if fn.endswith('.rpm'): pid = subprocess.Popen(["rpm", "--nosignature", "--queryformat", "%{DISTURL}", "-qp", t], stdout=subprocess.PIPE, close_fds=True) os.waitpid(pid.pid, 0)[1] disturl = pid.stdout.readlines() if not os.path.basename(disturl[0]).startswith(p.rev): p.error = "disturl %s does not match revision %s" % (disturl[0], p.rev) return [], [] toignore = [] for fn in self._check_repo_repo_list(p.tproject, 'standard', 'x86_64', p.tpackage, opts, ignore=True): toignore.append(fn[1]) # now fetch -32bit pack list for fn in self._check_repo_repo_list(p.tproject, 'standard', 'i586', p.tpackage, opts, ignore=True): if fn[2] != 'x86_64': continue toignore.append(fn[1]) return toignore, downloads def _get_build_deps(self, prj, repo, arch, pkg, opts): xml = get_buildinfo(opts.apiurl, prj, pkg, repo, arch) root = ET.fromstring(xml) return [e.attrib['name'] for e in root.findall('bdep')] def _get_base_build_bin(self, opts): """Get Base:build pagacke list""" binaries = {} for arch in ('x86_64', 'i586'): url = makeurl(opts.apiurl, ['/build/Base:build/standard/%s/_repository'%arch,]) f = http_GET(url) root = ET.parse(f).getroot() binaries[arch] = set([e.attrib['filename'][:-4] for e in root.findall('binary')]) return binaries def _get_base_build_src(self, opts): """Get Base:build pagacke list""" url = makeurl(opts.apiurl, ['/source/Base:build',]) f = http_GET(url) root = ET.parse(f).getroot() return set([e.attrib['name'] for e in root.findall('entry')]) def _check_repo_group(self, id_, reqs, opts): print '\nCheck group', reqs if not all(self._check_repo_buildsuccess(r, opts) for r in reqs): return # all succeeded toignore, downloads = [], [] destdir = os.path.expanduser('~/co/%s'%str(reqs[0].group)) fetched = {r: False for r in opts.groups.get(id_, [])} goodrepo = '' packs = [] for p in reqs: i, d = self._check_repo_download(p, destdir, opts) if p.error: print p.error p.updated = True self._check_repo_change_review_state(opts, p.request, 'new', message=p.error) return downloads.extend(d) toignore.extend(i) fetched[p.request] = True goodrepo = p.goodrepo packs.append(p) for req, f in fetched.items(): if not f: packs.extend(self._check_repo_fetch_request(req, opts)) for p in packs: p.goodrepo = goodrepo i, d = self._check_repo_download(p, destdir, opts) if p.error: print 'ALREADY ACEPTED:', p.error p.updated = True downloads.extend(d) toignore.extend(i) # Get all the Base:build packages (source and binary) base_build_bin = self._get_base_build_bin(opts) base_build_src = self._get_base_build_src(opts) for p in reqs: # Be sure that if the package is in Base:build, all the # dependecies are also in Base:build if p.spackage in base_build_src: # TODO - Check all the arch for this package for arch in ('x86_64', 'i586'): build_deps = set(self._get_build_deps(p.sproject, p.goodrepo, arch, p.spackage, opts)) outliers = build_deps - base_build_bin[arch] if outliers: print 'OUTLIERS (%s)'%arch, outliers for p in reqs: smissing = [] for package in p.missings: alreadyin=False print package, packs for t in packs: if package == t.tpackage: alreadyin=True if alreadyin: continue print package, packs, downloads, toignore request = self._check_repo_find_submit_request(opts, p.tproject, package) if request: greqs = opts.groups.get(p.group, []) if request in greqs: continue package = "%s(rq%s)" % (package, request) smissing.append(package) if len(smissing): msg = "please make sure to wait before these depencencies are in {0}: {1}".format(p.tproject, ', '.join(smissing)) self._check_repo_change_review_state(opts, p.request, 'new', message=msg) print 'UPDATED', msg return for dirname, dirnames, filenames in os.walk(destdir): if len(dirnames) + len(filenames) == 0: os.rmdir(dirname) for filename in filenames: fn = os.path.join(dirname, filename) if not fn in downloads: os.unlink(fn) civs = "LC_ALL=C perl /suse/coolo/checker/repo-checker.pl '%s' '%s' 2>&1" % (destdir, ','.join(toignore)) #exit(1) p = subprocess.Popen(civs, shell=True, stdout=subprocess.PIPE, close_fds=True) #ret = os.waitpid(p.pid, 0)[1] output, _ = p.communicate() ret = p.returncode updated = dict() if ret: print output, set(map(lambda x: x.request, reqs)) for p in reqs: if updated.get(p.request, False) or p.updated: continue self._check_repo_change_review_state(opts, p.request, 'new', message=output) updated[p.request] = 1 p.updated = True return for p in reqs: if updated.get(p.request, False) or p.updated: continue msg="Builds for repo %s" % p.goodrepo self._check_repo_change_review_state(opts, p.request, 'accepted', message=msg) updated[p.request] = 1 p.updated = True shutil.rmtree(destdir) def _check_repo_fetch_request(self, id_, opts): url = makeurl(opts.apiurl, ['request', str(id_)]) root = ET.parse(http_GET(url)).getroot() return self._check_repo_one_request(root, opts) @cmdln.alias('check', 'cr') @cmdln.option('-s', '--skip', action='store_true', help='skip review') def do_check_repo(self, subcmd, opts, *args): """${cmd_name}: Checker review of submit requests. Usage: ${cmd_name} [SRID]... Shows pending review requests and their current state. ${cmd_option_list} """ opts.mode = '' opts.groups = {} opts.grouped = {} opts.verbose = False opts.apiurl = self.get_api_url() if opts.skip: if not len(args): raise oscerr.WrongArgs('Please give, if you want to skip a review specify a SRID' ) for id_ in args: self._check_repo_change_review_state(opts, id_, 'accepted', message='skip review') return ids = [arg for arg in args if arg.isdigit()] packs = [] if not ids: # xpath query, using the -m, -r, -s options where = "@by_user='factory-repo-checker'+and+@state='new'" url = makeurl(opts.apiurl, ['search', 'request'], "match=state/@name='review'+and+review["+where+"]") f = http_GET(url) root = ET.parse(f).getroot() for rq in root.findall('request'): packs.extend(self._check_repo_one_request(rq, opts)) else: # we have a list, use them. for id_ in ids: packs.extend(self._check_repo_fetch_request(id_, opts)) groups = {} for p in packs: a = groups.get(p.group, []) a.append(p) groups[p.group] = a for id_, reqs in groups.items(): self._check_repo_group(id_, reqs, opts)