#!/usr/bin/python -tt # -*- coding: utf-8 -*- # # Copyright © 2013-2014 Red Hat, Inc. # # This copyrighted material is made available to anyone wishing to use, modify, # copy, or redistribute it subject to the terms and conditions of the GNU # General Public License v.2, or (at your option) any later version. This # program is distributed in the hope that it will be useful, but WITHOUT ANY # WARRANTY expressed or implied, including the implied warranties of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General # Public License for more details. You should have received a copy of the GNU # General Public License along with this program; if not, write to the Free # Software Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA # 02110-1301, USA. Any Red Hat trademarks that are incorporated in the source # code or documentation are not subject to the GNU General Public License and # may only be used or replicated with the express permission of Red Hat, Inc. # # Red Hat Author(s): Toshio Kuratomi # Author(s): Mike Watters # Author(s): Pierre-Yves Chibon # ''' sync information from the packagedb into bugzilla This short script takes information about package onwership and imports it into bugzilla. ''' ## These two lines are needed to run on EL6 __requires__ = ['SQLAlchemy >= 0.7', 'jinja2 >= 2.4'] import pkg_resources import argparse import datetime import time import sys import os import itertools import json import xmlrpclib import codecs import smtplib import bugzilla import requests from email.Message import Message from fedora.client.fas2 import AccountSystem if 'PKGDB2_CONFIG' not in os.environ \ and os.path.exists('/etc/pkgdb2/pkgdb2.cfg'): print 'Using configuration file `/etc/pkgdb2/pkgdb2.cfg`' os.environ['PKGDB2_CONFIG'] = '/etc/pkgdb2/pkgdb2.cfg' try: import pkgdb2 except ImportError: sys.path.insert( 0, os.path.join(os.path.dirname(os.path.realpath(__file__)), '..')) import pkgdb2 BZSERVER = pkgdb2.APP.config.get('PKGDB2_BUGZILLA_URL') BZUSER = pkgdb2.APP.config.get('PKGDB2_BUGZILLA_NOTIFY_USER') BZPASS = pkgdb2.APP.config.get('PKGDB2_BUGZILLA_NOTIFY_PASSWORD') BZCOMPAPI = pkgdb2.APP.config.get('BUGZILLA_COMPONENT_API') FASURL = pkgdb2.APP.config.get('PKGDB2_FAS_URL') FASUSER = pkgdb2.APP.config.get('PKGDB2_FAS_USER') FASPASS = pkgdb2.APP.config.get('PKGDB2_FAS_PASSWORD') FASINSECURE = pkgdb2.APP.config.get('PKGDB2_FAS_INSECURE') NOTIFYEMAIL = pkgdb2.APP.config.get('PKGDB2_BUGZILLA_NOTIFY_EMAIL') PKGDBSERVER = pkgdb2.APP.config.get('SITE_URL') DRY_RUN = pkgdb2.APP.config.get('PKGDB2_BUGZILLA_DRY_RUN', False) EMAIL_FROM = 'accounts@fedoraproject.org' DATA_CACHE = '/var/tmp/pkgdb_sync_bz.json' # When querying for current info, take segments of 1000 packages a time BZ_PKG_SEGMENT = 1000 class DataChangedError(Exception): '''Raised when data we are manipulating changes while we're modifying it.''' pass def segment(iterable, chunk, fill=None): '''Collect data into `chunk` sized block''' args = [iter(iterable)] * chunk return itertools.izip_longest(*args, fillvalue=fill) class ProductCache(dict): def __init__(self, bz, acls): self.bz = bz self.acls = acls # Ask bugzilla for a section of the pkglist. # Save the information from the section that we want. def __getitem__(self, key): try: return super(ProductCache, self).__getitem__(key) except KeyError: # We can only cache products we have pkgdb information for if key not in self.acls: raise if BZCOMPAPI == 'getcomponentsdetails': # Old API -- in python-bugzilla. But with current server, this # gives ProxyError products = self.server.getcomponentsdetails(key) elif BZCOMPAPI == 'component.get': # Way that's undocumented in the partner-bugzilla api but works # currently pkglist = acls[key].keys() products = {} for pkg_segment in segment(pkglist, BZ_PKG_SEGMENT): # Format that bugzilla will understand. Strip None's that segment() pads # out the final data segment() with query = [dict(product=key, component=p) for p in pkg_segment if p is not None] raw_data = self.bz._proxy.Component.get(dict(names=query)) for package in raw_data['components']: # Reformat data to be the same as what's returned from # getcomponentsdetails product = dict(initialowner=package['default_assignee'], description=package['description'], initialqacontact=package['default_qa_contact'], initialcclist=package['default_cc']) products[package['name'].lower()] = product self[key] = products return super(ProductCache, self).__getitem__(key) class Bugzilla(object): def __init__(self, bzServer, username, password, acls): self.bzXmlRpcServer = bzServer self.username = username self.password = password self.server = bugzilla.Bugzilla( url=self.bzXmlRpcServer, user=self.username, password=self.password) self.productCache = ProductCache(self.server, acls) # Connect to the fedora account system self.fas = AccountSystem( base_url=FASURL, username=FASUSER, password=FASPASS) self.userCache = self.fas.people_by_key( key='username', fields=['bugzilla_email']) def _get_bugzilla_email(self, username): '''Return the bugzilla email address for a user. First looks in a cache for a username => bugzilla email. If not found, reloads the cache from fas and tries again. ''' try: return self.userCache[username]['bugzilla_email'].lower() except KeyError: if username.startswith('@'): group = self.fas.group_by_name(username[1:]) self.userCache[username] = { 'bugzilla_email': group.mailing_list} else: person = self.fas.person_by_username(username) bz_email = person.get('bugzilla_email', None) if bz_email is None: print '%s has no bugzilla email, valid account?' % username else: self.userCache[username] = {'bugzilla_email': bz_email} return self.userCache[username]['bugzilla_email'].lower() def add_edit_component(self, package, collection, owner, description, qacontact=None, cclist=None): '''Add or update a component to have the values specified. ''' # Turn the cclist into something usable by bugzilla if not cclist or 'people' not in cclist: initialCCList = list() else: initialCCList = [ self._get_bugzilla_email(cc) for cc in cclist['people']] if 'groups' in cclist: group_cc = [ self._get_bugzilla_email(cc) for cc in cclist['groups']] initialCCList.extend(group_cc) # Add owner to the cclist so comaintainers taking over a bug don't # have to do this manually owner = self._get_bugzilla_email(owner) if owner not in initialCCList: initialCCList.append(owner) # Lookup product try: product = self.productCache[collection] except xmlrpclib.Fault as e: # Output something useful in args e.args = (e.faultCode, e.faultString) raise except xmlrpclib.ProtocolError as e: e.args = ('ProtocolError', e.errcode, e.errmsg) raise pkgKey = package.lower() if pkgKey in product: # edit the package information data = {} # Grab bugzilla email for things changable via xmlrpc if qacontact: qacontact = self._get_bugzilla_email(qacontact) else: qacontact = 'extras-qa@fedoraproject.org' # Check for changes to the owner, qacontact, or description if product[pkgKey]['initialowner'] != owner: data['initialowner'] = owner if product[pkgKey]['description'] != description: data['description'] = description if product[pkgKey]['initialqacontact'] != qacontact and ( qacontact or product[pkgKey]['initialqacontact']): data['initialqacontact'] = qacontact if len(product[pkgKey]['initialcclist']) != len(initialCCList): data['initialcclist'] = initialCCList else: for ccMember in product[pkgKey]['initialcclist']: if ccMember not in initialCCList: data['initialcclist'] = initialCCList break if data: ### FIXME: initialowner has been made mandatory for some # reason. Asking dkl why. data['initialowner'] = owner # Changes occurred. Submit a request to change via xmlrpc data['product'] = collection data['component'] = package if DRY_RUN: print '[EDITCOMP] Changing via editComponent(' \ '%s, %s, "xxxxx")' % (data, self.username) print '[EDITCOMP] Former values: %s|%s|%s|%s' % ( product[pkgKey]['initialowner'], product[pkgKey]['description'], product[pkgKey]['initialqacontact'], product[pkgKey]['initialcclist']) else: try: self.server.editcomponent(data) except xmlrpclib.Fault, e: # Output something useful in args e.args = (data, e.faultCode, e.faultString) raise except xmlrpclib.ProtocolError, e: e.args = ('ProtocolError', e.errcode, e.errmsg) raise else: # Add component if qacontact: qacontact = self._get_bugzilla_email(qacontact) else: qacontact = 'extras-qa@fedoraproject.org' data = { 'product': collection, 'component': package, 'description': description, 'initialowner': owner, 'initialqacontact': qacontact } if initialCCList: data['initialcclist'] = initialCCList if DRY_RUN: print '[ADDCOMP] Adding new component AddComponent:(' \ '%s, %s, "xxxxx")' % (data, self.username) else: try: self.server.addcomponent(data) except xmlrpclib.Fault, e: # Output something useful in args e.args = (data, e.faultCode, e.faultString) raise def send_email(fromAddress, toAddress, subject, message, ccAddress=None): '''Send an email if there's an error. This will be replaced by sending messages to a log later. ''' msg = Message() msg.add_header('To', ','.join(toAddress)) msg.add_header('From', fromAddress) msg.add_header('Subject', subject) if ccAddress is not None: msg.add_header('Cc', ','.join(ccAddress)) msg.set_payload(message) smtp = smtplib.SMTP('bastion') smtp.sendmail(fromAddress, toAddress, msg.as_string()) smtp.quit() def notify_users(errors): ''' Browse the list of errors and when we can retrieve the email address, use it to notify the user about the issue. ''' tmpl_email = pkgdb2.APP.config.get('PKGDB_SYNC_BUGZILLA_EMAIL', None) if not tmpl_email: print 'No template email configured in the configuration file, '\ 'no notification sent to the users' return data = {} if os.path.exists(DATA_CACHE): try: with open(DATA_CACHE) as stream: data = json.load(stream) except Exception as err: print 'Could not read the json file at %s: \nError: %s' % ( DATA_CACHE, err) new_data = {} for error in errors: notify_user = False if 'The name ' in error and ' is not a valid username' in error: user_email = error.split(' is not a valid username')[0].split( 'The name ')[1].strip() now = datetime.datetime.utcnow() # See if we already know about this user if user_email in data and data[user_email]['last_update']: last_update = datetime.datetime.fromtimestamp( int(data[user_email]['last_update'])) # Only notify users once per hour if (now - last_update).seconds >= 3600: notify_user = True else: new_data[user_email] = data[user_email] elif not data or user_email not in data: notify_user = True if notify_user: send_email( EMAIL_FROM, [user_email], subject='Please fix your bugzilla.redhat.com account', message=tmpl_email, ccAddress=NOTIFYEMAIL, ) new_data[user_email] = { 'last_update': time.mktime(now.timetuple()) } with open(DATA_CACHE, 'w') as stream: json.dump(new_data, stream) if __name__ == '__main__': sys.stdout = codecs.getwriter('utf-8')(sys.stdout) parser = argparse.ArgumentParser( description='Script syncing information between pkgdb and bugzilla' ) parser.add_argument( '--debug', dest='debug', action='store_true', default=False, help='Print the changes instead of making them in bugzilla') args = parser.parse_args() if args.debug: DRY_RUN = True # Non-fatal errors to alert people about errors = [] # Get bugzilla information from the package database req = requests.get('%s/api/bugzilla/?format=json' % PKGDBSERVER) acls = req.json()['bugzillaAcls'] # Initialize the connection to bugzilla bugzilla = Bugzilla(BZSERVER, BZUSER, BZPASS, acls) for product in acls.keys(): if product not in ('Fedora', 'Fedora EPEL'): continue for pkg in sorted(acls[product]): if DRY_RUN: print pkg pkgInfo = acls[product][pkg] try: bugzilla.add_edit_component( pkg, product, pkgInfo['owner'], pkgInfo['summary'], pkgInfo['qacontact'], pkgInfo['cclist']) except ValueError, e: # A username didn't have a bugzilla address errors.append(str(e.args)) except DataChangedError, e: # A Package or Collection was returned via xmlrpc but wasn't # present when we tried to change it errors.append(str(e.args)) except xmlrpclib.ProtocolError, e: # Unrecoverable and likely means that nothing is going to # succeed. errors.append(str(e.args)) break except xmlrpclib.Error, e: # An error occurred in the xmlrpc call. Shouldn't happen but # we better see what it is errors.append('%s -- %s' % (pkg, e.args[-1])) # Send notification of errors if errors: if DRY_RUN: print '[DEBUG]', '\n'.join(errors) else: notify_users(errors) send_email( EMAIL_FROM, NOTIFYEMAIL, 'Errors while syncing bugzilla with the PackageDB', ''' The following errors were encountered while updating bugzilla with information from the Package Database. Please have the problems taken care of: %s ''' % ('\n'.join(errors),)) sys.exit(0)