Package bodhi :: Module model
[hide private]
[frames] | no frames]

Source Code for Module bodhi.model

  1  # $Id: model.py,v 1.9 2007/01/08 06:07:07 lmacken Exp $ 
  2  # This program is free software; you can redistribute it and/or modify 
  3  # it under the terms of the GNU General Public License as published by 
  4  # the Free Software Foundation; version 2 of the License. 
  5  # 
  6  # This program is distributed in the hope that it will be useful, 
  7  # but WITHOUT ANY WARRANTY; without even the implied warranty of 
  8  # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the 
  9  # GNU Library General Public License for more details. 
 10  # 
 11  # You should have received a copy of the GNU General Public License 
 12  # along with this program; if not, write to the Free Software 
 13  # Foundation, Inc., 59 Temple Place - Suite 330, Boston, MA 02111-1307, USA. 
 14   
 15  import os 
 16  import rpm 
 17  import time 
 18  import logging 
 19  import xmlrpclib 
 20  import turbogears 
 21   
 22  from sqlobject import * 
 23  from datetime import datetime 
 24   
 25  from turbogears import config, flash 
 26  from turbogears.database import PackageHub 
 27   
 28  from os.path import isfile, join 
 29  from textwrap import wrap 
 30   
 31  from bodhi import buildsys, mail 
 32  from bodhi.util import get_nvr, rpm_fileheader, header, get_age, get_age_in_days 
 33  from bodhi.exceptions import RPMNotFound 
 34  from bodhi.identity.tables import * 
 35   
 36  log = logging.getLogger(__name__) 
 37  hub = PackageHub("bodhi") 
 38  __connection__ = hub 
 39   
 40  soClasses=('Release', 'Package', 'PackageBuild', 'PackageUpdate', 'CVE', 
 41             'Bugzilla', 'Comment', 'User', 'Group', 'Visit') 
 42   
43 -class Release(SQLObject):
44 """ Table of releases that we will be pushing updates for """ 45 name = UnicodeCol(alternateID=True, notNone=True) 46 long_name = UnicodeCol(notNone=True) 47 updates = MultipleJoin('PackageUpdate', joinColumn='release_id') 48 id_prefix = UnicodeCol(notNone=True) 49 dist_tag = UnicodeCol(notNone=True) # ie dist-fc7
50
51 -class Package(SQLObject):
52 name = UnicodeCol(alternateID=True, notNone=True) 53 builds = MultipleJoin('PackageBuild', joinColumn='package_id') 54 suggest_reboot = BoolCol(default=False) 55
56 - def updates(self):
57 for build in self.builds: 58 for update in build.updates: 59 yield update
60
61 - def num_updates(self):
62 i = 0 63 for build in self.builds: 64 i += len(build.updates) 65 return i
66
67 - def __str__(self):
68 x = header(self.name) 69 states = { 'pending' : [], 'testing' : [], 'stable' : [] } 70 if len(self.builds): 71 for build in self.builds: 72 for state in states.keys(): 73 states[state] += filter(lambda u: u.status == state, 74 build.updates) 75 for state in states.keys(): 76 if len(states[state]): 77 x += "\n %s Updates (%d)\n" % (state.title(), 78 len(states[state])) 79 for update in states[state]: 80 x += " o %s\n" % update.title 81 return x
82
83 -class PackageBuild(SQLObject):
84 nvr = UnicodeCol(notNone=True, alternateID=True) 85 package = ForeignKey('Package') 86 updates = RelatedJoin("PackageUpdate") 87
88 - def get_rpm_header(self):
89 """ Get the rpm header of this build """ 90 return rpm_fileheader(self.get_srpm_path())
91
92 - def get_changelog(self, timelimit=0):
93 """ 94 Retrieve the RPM changelog of this package since it's last update 95 """ 96 rpm_header = self.get_rpm_header() 97 descrip = rpm_header[rpm.RPMTAG_CHANGELOGTEXT] 98 if not descrip: return "" 99 100 who = rpm_header[rpm.RPMTAG_CHANGELOGNAME] 101 when = rpm_header[rpm.RPMTAG_CHANGELOGTIME] 102 103 num = len(descrip) 104 if num == 1: when = [when] 105 106 str = "" 107 i = 0 108 while (i < num) and (when[i] > timelimit): 109 str += '* %s %s\n%s\n' % (time.strftime("%a %b %e %Y", 110 time.localtime(when[i])), who[i], 111 descrip[i]) 112 i += 1 113 return str
114
115 - def get_srpm_path(self):
116 """ Return the path to the SRPM for this update """ 117 src_path = self.get_source_path() 118 path = src_path.split('/') 119 srpm = join(src_path, "src", "%s.src.rpm" % ('-'.join(path[-3:]))) 120 if not isfile(srpm): 121 log.debug("Cannot find SRPM: %s" % srpm) 122 raise RPMNotFound 123 return srpm
124
125 - def get_source_path(self):
126 """ Return the path of this built update """ 127 return join(config.get('build_dir'), *get_nvr(self.nvr))
128
129 - def get_latest(self):
130 """ Return the path to the last released srpm of this package """ 131 latest_srpm = None 132 koji_session = buildsys.get_session() 133 134 # Grab a list of builds tagged with dist-$RELEASE-updates, and find 135 # the most recent update for this package, other than this one. If 136 # nothing is tagged for -updates, then grab the first thing in 137 # dist-$RELEASE. We aren't checking -updates-candidate first, because 138 # there could potentially be packages that never make their way over 139 # -updates, so we don't want to generate ChangeLogs against those. 140 nvr = get_nvr(self.nvr) 141 for tag in ['%s-updates', '%s']: 142 tag %= self.updates[0].release.dist_tag 143 builds = koji_session.getLatestBuilds(tag, None, self.package.name) 144 latest = None 145 146 # Find the first build that is older than us 147 for build in builds: 148 if rpm.labelCompare(nvr, get_nvr(build['nvr'])) > 0: 149 latest = get_nvr(build['nvr']) 150 break 151 152 if latest: 153 srpm_path = join(config.get('build_dir'), latest[0], 154 latest[1], latest[2], 'src', 155 '%s.src.rpm' % '-'.join(latest)) 156 if isfile(srpm_path): 157 log.debug("Latest build before %s: %s" % (self.nvr, 158 srpm_path)) 159 latest_srpm = srpm_path 160 else: 161 log.warning("Latest build %s not found" % srpm_path) 162 break 163 164 return latest_srpm
165
166 -class PackageUpdate(SQLObject):
167 """ This class defines an update in our system. """ 168 title = UnicodeCol(notNone=True, alternateID=True, unique=True) 169 builds = RelatedJoin("PackageBuild") 170 date_submitted = DateTimeCol(default=datetime.utcnow, notNone=True) 171 date_modified = DateTimeCol(default=None) 172 date_pushed = DateTimeCol(default=None) 173 submitter = UnicodeCol(notNone=True) 174 update_id = UnicodeCol(default=None) 175 type = EnumCol(enumValues=['security', 'bugfix', 'enhancement']) 176 cves = RelatedJoin("CVE") 177 bugs = RelatedJoin("Bugzilla") 178 release = ForeignKey('Release') 179 status = EnumCol(enumValues=['pending', 'testing', 'stable', 180 'obsolete'], default='pending') 181 pushed = BoolCol(default=False) 182 notes = UnicodeCol() 183 request = EnumCol(enumValues=['testing', 'stable', 'obsolete', 184 None], default=None) 185 comments = MultipleJoin('Comment', joinColumn='update_id') 186 karma = IntCol(default=0) 187 close_bugs = BoolCol(default=True) 188 nagged = PickleCol(default=None) # { nagmail_name : datetime, ... } 189
190 - def get_title(self, delim=' '):
191 return delim.join([build.nvr for build in self.builds])
192
193 - def get_bugstring(self, show_titles=False):
194 """ Return a space-delimited string of bug numbers for this update """ 195 val = '' 196 if show_titles: 197 i = 0 198 for bug in self.bugs: 199 bugstr = '%s%s - %s\n' % (i and ' ' * 11 + ': ' or '', 200 bug.bz_id, bug.title) 201 val += '\n'.join(wrap(bugstr, width=67, 202 subsequent_indent=' ' * 11 + ': ')) + '\n' 203 i += 1 204 val = val[:-1] 205 else: 206 val = ' '.join([str(bug.bz_id) for bug in self.bugs]) 207 return val
208
209 - def get_cvestring(self):
210 """ Return a space-delimited string of CVE ids for this update """ 211 return ' '.join([cve.cve_id for cve in self.cves])
212
213 - def assign_id(self):
214 """ 215 Assign an update ID to this update. This function finds the next number 216 in the sequence of pushed updates for this release, increments it and 217 prefixes it with the id_prefix of the release and the year 218 (ie FEDORA-2007-0001) 219 """ 220 if self.update_id != None and self.update_id != u'None': 221 log.debug("Keeping current update id %s" % self.update_id) 222 return 223 update = PackageUpdate.select(PackageUpdate.q.update_id != 'None', 224 orderBy=PackageUpdate.q.update_id) 225 try: 226 id = int(update[-1].update_id.split('-')[-1]) + 1 227 except (AttributeError, IndexError): 228 id = 1 229 self.update_id = u'%s-%s-%0.4d' % (self.release.id_prefix, 230 time.localtime()[0],id) 231 log.debug("Setting update_id for %s to %s" % (self.title, 232 self.update_id)) 233 hub.commit()
234
235 - def request_complete(self):
236 """ 237 Perform post-request actions. 238 """ 239 if self.request == 'testing': 240 self.pushed = True 241 self.date_pushed = datetime.utcnow() 242 self.status = 'testing' 243 self.assign_id() 244 self.send_update_notice() 245 map(lambda bug: bug.add_comment(self), self.bugs) 246 self.comment('This update has been pushed to testing', 247 author='bodhi') 248 elif self.request == 'obsolete': 249 self.comment('This update has been obsoleted', author='bodhi') 250 self.pushed = False 251 self.status = 'obsolete' 252 elif self.request == 'stable': 253 self.pushed = True 254 self.date_pushed = datetime.utcnow() 255 self.status = 'stable' 256 self.assign_id() 257 self.comment('This update has been pushed to stable', 258 author='bodhi') 259 self.send_update_notice() 260 map(lambda bug: bug.add_comment(self), self.bugs) 261 if self.close_bugs: 262 map(lambda bug: bug.close_bug(self), self.bugs) 263 264 log.info("%s request on %s complete!" % (self.request, self.title)) 265 self.request = None 266 hub.commit()
267
268 - def send_update_notice(self):
269 import turbomail 270 log.debug("Sending update notice for %s" % self.title) 271 mailinglist = None 272 sender = config.get('bodhi_email') 273 if not sender: 274 log.error("bodhi_email not defined in configuration! Unable " + 275 "to send update notice") 276 return 277 if self.status == 'stable': 278 mailinglist = config.get('%s_announce_list' % 279 self.release.id_prefix.lower()) 280 elif self.status == 'testing': 281 mailinglist = config.get('%s_test_announce_list' % 282 self.release.id_prefix.lower()) 283 if mailinglist: 284 for subject, body in mail.get_template(self): 285 message = turbomail.Message(sender, mailinglist, subject) 286 message.plain = body 287 try: 288 turbomail.enqueue(message) 289 log.debug("Sending mail: %s" % message.plain) 290 except turbomail.MailNotEnabledException: 291 log.warning("mail.on is not True!") 292 else: 293 log.error("Cannot find mailing list address for update notice")
294
295 - def get_url(self):
296 """ Return the relative URL to this update """ 297 status = self.status == 'testing' and 'testing/' or '' 298 if not self.pushed: status = 'pending/' 299 return '/%s%s/%s' % (status, self.release.name, self.title)
300
301 - def __str__(self):
302 """ 303 Return a string representation of this update. 304 """ 305 val = header(self.title.replace(',', ', ')) 306 if self.update_id: 307 val += " Update ID: %s\n" % self.update_id 308 val += """ Release: %s 309 Status: %s 310 Type: %s 311 Karma: %d""" % (self.release.long_name,self.status,self.type,self.karma) 312 if self.request != None: 313 val += "\n Request: %s" % self.request 314 if len(self.bugs): 315 bugs = self.get_bugstring(show_titles=True) 316 val += "\n Bugs: %s" % bugs 317 if len(self.cves): 318 val += "\n CVEs: %s" % self.get_cvestring() 319 if self.notes: 320 notes = wrap(self.notes, width=67, subsequent_indent=' '*11 +': ') 321 val += "\n Notes: %s" % '\n'.join(notes) 322 val += """ 323 Submitter: %s 324 Submitted: %s\n\n %s 325 """ % (self.submitter, self.date_submitted, 326 config.get('base_address') + turbogears.url(self.get_url())) 327 return val.rstrip()
328
329 - def get_build_tag(self):
330 """ 331 Get the tag that this build is currently tagged with. 332 TODO: we should probably get this stuff from koji instead of guessing 333 """ 334 tag = '%s-updates' % self.release.dist_tag 335 if self.status == 'pending': 336 tag += '-candidate' 337 elif self.status == 'testing': 338 tag += '-testing' 339 return tag
340
341 - def update_bugs(self, bugs):
342 """ 343 Create any new bugs, and remove any missing ones. Destroy removed bugs 344 that are no longer referenced anymore 345 """ 346 for bug in self.bugs: 347 if bug.bz_id not in bugs: 348 self.removeBugzilla(bug) 349 if len(bug.updates) == 0: 350 log.debug("Destroying stray Bugzilla #%d" % bug.bz_id) 351 bug.destroySelf() 352 for bug in bugs: 353 try: 354 bz = Bugzilla.byBz_id(bug) 355 if bz not in self.bugs: 356 self.addBugzilla(bz) 357 except SQLObjectNotFound: 358 bz = Bugzilla(bz_id=bug) 359 self.addBugzilla(bz)
360
361 - def update_cves(self, cves):
362 """ 363 Create any new CVES, and remove any missing ones. Destroy removed CVES 364 that are no longer referenced anymore. 365 """ 366 for cve in self.cves: 367 if cve.cve_id not in cves: 368 log.debug("Removing CVE %s from %s" % (cve.cve_id, self.title)) 369 self.removeCVE(cve) 370 if cve.cve_id not in cves and len(cve.updates) == 0: 371 log.debug("Destroying stray CVE #%s" % cve.cve_id) 372 cve.destroySelf() 373 for cve_id in cves: 374 try: 375 cve = CVE.byCve_id(cve_id) 376 if cve not in self.cves: 377 self.addCVE(cve) 378 except SQLObjectNotFound: 379 log.debug("Creating new CVE: %s" % cve_id) 380 cve = CVE(cve_id=cve_id) 381 self.addCVE(cve)
382
383 - def get_pushed_age(self):
384 return get_age(self.date_pushed)
385
386 - def get_submitted_age(self):
387 return get_age(self.date_submitted)
388 - def get_pushed_color(self):
389 age = get_age_in_days(self.date_pushed) 390 if age == 0: 391 color = '#ff0000' # red 392 elif age < 4: 393 color = '#ff6600' # orange 394 elif age < 7: 395 color = '#ffff00' # yellow 396 else: 397 color = '#00ff00' # green 398 return color
399
400 - def comment(self, text, karma=0, author=None):
401 """ 402 Add a comment to this update, adjusting the karma appropriately. 403 Each user can adjust an updates karma once in each direction, thus 404 being able to negate their original choice. If the karma reaches 405 the 'stable_karma' configuration option, then request that this update 406 be marked as stable. 407 """ 408 stable_karma = config.get('stable_karma') 409 if not author: author = identity.current.user_name 410 if not filter(lambda c: c.author == author and 411 c.karma == karma, self.comments): 412 self.karma += karma 413 log.info("Updated %s karma to %d" % (self.title, self.karma)) 414 if stable_karma and stable_karma == self.karma: 415 log.info("Automatically marking %s as stable" % self.title) 416 self.request = 'stable' 417 mail.send(self.submitter, 'stablekarma', self) 418 mail.send_admin('move', self) 419 comment = Comment(text=text, karma=karma, update=self, author=author) 420 421 # Send a notification to everyone that has commented on this update 422 people = set() 423 people.add(self.submitter) 424 map(lambda comment: people.add(comment.author), self.comments) 425 if 'bodhi' in people: 426 people.remove('bodhi') 427 for person in people: 428 mail.send(person, 'comment', self)
429
430 -class Comment(SQLObject):
431 timestamp = DateTimeCol(default=datetime.now) 432 update = ForeignKey("PackageUpdate", notNone=True) 433 author = UnicodeCol(notNone=True) 434 karma = IntCol(default=0) 435 text = UnicodeCol() 436
437 - def __str__(self):
438 karma = '0' 439 if self.karma != 0: 440 karma = '%+d' % (self.karma,) 441 return "%s - %s (karma: %s)\n%s" % (self.author, self.timestamp, 442 karma, self.text)
443
444 -class CVE(SQLObject):
445 """ Table of CVEs fixed within updates that we know of. """ 446 cve_id = UnicodeCol(alternateID=True, notNone=True) 447 updates = RelatedJoin("PackageUpdate") 448
449 - def get_url(self):
450 return "http://www.cve.mitre.org/cgi-bin/cvename.cgi?name=%s" % self.cve_id
451
452 -class Bugzilla(SQLObject):
453 """ Table of Bugzillas that we know about. """ 454 bz_id = IntCol(alternateID=True) 455 title = UnicodeCol(default=None) 456 updates = RelatedJoin("PackageUpdate") 457 security = BoolCol(default=False) 458 459 _bz_server = config.get("bz_server") 460 default_msg = "%s has been pushed to the %s repository. If problems " + \ 461 "still persist, please make note of it in this bug report." 462
463 - def _set_bz_id(self, bz_id):
464 """ 465 When the ID for this bug is set (upon creation), go out and fetch the 466 details and check if this bug is security related. 467 """ 468 self._SO_set_bz_id(bz_id) 469 self._fetch_details()
470
471 - def _fetch_details(self):
472 try: 473 log.debug("Fetching bugzilla #%d" % self.bz_id) 474 server = xmlrpclib.Server(self._bz_server) 475 me = config.get('bodhi_email') 476 password = config.get('bodhi_password') 477 if not password: 478 log.error("No password stored for bodhi_email") 479 return 480 bug = server.bugzilla.getBug(self.bz_id, me, password) 481 del server 482 self.title = bug['short_desc'] 483 if bug['keywords'].lower().find('security') != -1: 484 self.security = True 485 except xmlrpclib.Fault, f: 486 self.title = 'Invalid bug number' 487 log.warning("Got fault from Bugzilla: %s" % str(f)) 488 except Exception, e: 489 self.title = 'Unable to fetch bug title' 490 log.error(self.title + ': ' + str(e))
491
492 - def default_message(self, update):
493 message = self.default_msg % (update.get_title(delim=', '), "%s %s" % 494 (update.release.long_name, update.status)) 495 if update.status == "testing": 496 message += "\n If you want to test the update, you can install " + \ 497 "it with \n su -c 'yum --enablerepo=updates-testing " + \ 498 "update %s'" % (' '.join([build.package.name for build 499 in update.builds])) 500 501 return message
502
503 - def add_comment(self, update, comment=None):
504 me = config.get('bodhi_email') 505 password = config.get('bodhi_password', None) 506 if password: 507 if not comment: 508 comment = self.default_message(update) 509 log.debug("Adding comment to Bug #%d: %s" % (self.bz_id, comment)) 510 try: 511 server = xmlrpclib.Server(self._bz_server) 512 server.bugzilla.addComment(self.bz_id, comment, me, password, 0) 513 del server 514 except Exception, e: 515 log.error("Unable to add comment to bug #%d\n%s" % (self.bz_id, 516 str(e))) 517 else: 518 log.warning("bodhi_password not defined; unable to modify bug")
519
520 - def close_bug(self, update):
521 me = config.get('bodhi_email') 522 password = config.get('bodhi_password') 523 if password: 524 log.debug("Closing Bug #%d" % self.bz_id) 525 ver = '-'.join(get_nvr(update.builds[0].nvr)[-2:]) 526 try: 527 server = xmlrpclib.Server(self._bz_server) 528 server.bugzilla.closeBug(self.bz_id, 'ERRATA', me, 529 password, 0, ver) 530 del server 531 except Exception, e: 532 log.error("Cannot close bug #%d" % self.bz_id) 533 else: 534 log.warning("bodhi_password not defined; unable to close bug")
535
536 - def get_url(self):
537 return "https://bugzilla.redhat.com/show_bug.cgi?id=%s" % self.bz_id
538 539 ## Static list of releases -- used by master.kid, and the NewUpdateForm widget 540 global _releases 541 _releases = None
542 -def releases():
543 global _releases 544 if not _releases: 545 _releases = [(rel.name, rel.long_name, rel.id) for rel in \ 546 Release.select()] 547 for release in _releases: 548 yield release
549