# Copyright 2009 The Go Authors. All rights reserved. # Use of this source code is governed by a BSD-style # license that can be found in the LICENSE file. # This is the server part of the continuous build system for Go. It must be run # by AppEngine. from django.utils import simplejson from google.appengine.api import mail from google.appengine.api import memcache from google.appengine.ext import db from google.appengine.ext import webapp from google.appengine.ext.webapp import template from google.appengine.ext.webapp.util import run_wsgi_app import datetime import hashlib import hmac import logging import os import re import bz2 # local imports import key import const # The majority of our state are commit objects. One of these exists for each of # the commits known to the build system. Their key names are of the form # "-" . This means that a sorting by the key # name is sufficient to order the commits. # # The commit numbers are purely local. They need not match up to the commit # numbers in an hg repo. When inserting a new commit, the parent commit must be # given and this is used to generate the new commit number. In order to create # the first Commit object, a special command (/init) is used. class Commit(db.Model): num = db.IntegerProperty() # internal, monotonic counter. node = db.StringProperty() # Hg hash parentnode = db.StringProperty() # Hg hash user = db.StringProperty() date = db.DateTimeProperty() desc = db.BlobProperty() # This is the list of builds. Each element is a string of the form '`' . If the log hash is empty, then the build was # successful. builds = db.StringListProperty() fail_notification_sent = db.BooleanProperty() # A CompressedLog contains the textual build log of a failed build. # The key name is the hex digest of the SHA256 hash of the contents. # The contents is bz2 compressed. class CompressedLog(db.Model): log = db.BlobProperty() N = 30 def builderInfo(b): f = b.split('-', 3) goos = f[0] goarch = f[1] note = "" if len(f) > 2: note = f[2] return {'name': b, 'goos': goos, 'goarch': goarch, 'note': note} def builderset(): q = Commit.all() q.order('-__key__') results = q.fetch(N) builders = set() for c in results: builders.update(set(parseBuild(build)['builder'] for build in c.builds)) return builders class MainPage(webapp.RequestHandler): def get(self): self.response.headers['Content-Type'] = 'text/html; charset=utf-8' try: page = int(self.request.get('p', 1)) if not page > 0: raise except: page = 1 try: num = int(self.request.get('n', N)) if num <= 0 or num > 200: raise except: num = N offset = (page-1) * num q = Commit.all() q.order('-__key__') results = q.fetch(num, offset) revs = [toRev(r) for r in results] builders = {} for r in revs: for b in r['builds']: builders[b['builder']] = builderInfo(b['builder']) for r in revs: have = set(x['builder'] for x in r['builds']) need = set(builders.keys()).difference(have) for n in need: r['builds'].append({'builder': n, 'log':'', 'ok': False}) r['builds'].sort(cmp = byBuilder) builders = list(builders.items()) builders.sort() values = {"revs": revs, "builders": [v for k,v in builders]} values['num'] = num values['prev'] = page - 1 if len(results) == num: values['next'] = page + 1 path = os.path.join(os.path.dirname(__file__), 'main.html') self.response.out.write(template.render(path, values)) # A DashboardHandler is a webapp.RequestHandler but provides # authenticated_post - called by post after authenticating # json - writes object in json format to response output class DashboardHandler(webapp.RequestHandler): def post(self): if not auth(self.request): self.response.set_status(403) return self.authenticated_post() def authenticated_post(self): return def json(self, obj): self.response.set_status(200) simplejson.dump(obj, self.response.out) return def auth(req): k = req.get('key') return k == hmac.new(key.accessKey, req.get('builder')).hexdigest() or k == key.accessKey # Todo serves /todo. It tells the builder which commits need to be built. class Todo(DashboardHandler): def get(self): builder = self.request.get('builder') key = 'todo-%s' % builder response = memcache.get(key) if response is None: # Fell out of memcache. Rebuild from datastore results. # We walk the commit list looking for nodes that have not # been built by this builder. q = Commit.all() q.order('-__key__') todo = [] first = None for c in q.fetch(N+1): if first is None: first = c if not built(c, builder): todo.append({'Hash': c.node}) response = simplejson.dumps(todo) memcache.set(key, response, 3600) self.response.set_status(200) self.response.out.write(response) def built(c, builder): for b in c.builds: if b.startswith(builder+'`'): return True return False # Log serves /log/. It retrieves log data by content hash. class LogHandler(DashboardHandler): def get(self): self.response.headers['Content-Type'] = 'text/plain; charset=utf-8' hash = self.request.path[5:] l = CompressedLog.get_by_key_name(hash) if l is None: self.response.set_status(404) return log = bz2.decompress(l.log) self.response.set_status(200) self.response.out.write(log) # Init creates the commit with id 0. Since this commit doesn't have a parent, # it cannot be created by Build. class Init(DashboardHandler): def authenticated_post(self): date = parseDate(self.request.get('date')) node = self.request.get('node') if not validNode(node) or date is None: logging.error("Not valid node ('%s') or bad date (%s %s)", node, date, self.request.get('date')) self.response.set_status(500) return commit = Commit(key_name = '00000000-%s' % node) commit.num = 0 commit.node = node commit.parentnode = '' commit.user = self.request.get('user').encode('utf8') commit.date = date commit.desc = self.request.get('desc').encode('utf8') commit.put() self.response.set_status(200) # The last commit when we switched to using entity groups. # This is the root of the new commit entity group. RootCommitKeyName = '00000f26-f32c6f1038207c55d5780231f7484f311020747e' # CommitHandler serves /commit. # A GET of /commit retrieves information about the specified commit. # A POST of /commit creates a node for the given commit. # If the commit already exists, the POST silently succeeds (like mkdir -p). class CommitHandler(DashboardHandler): def get(self): node = self.request.get('node') if not validNode(node): return self.json({'Status': 'FAIL', 'Error': 'malformed node hash'}) n = nodeByHash(node) if n is None: return self.json({'Status': 'FAIL', 'Error': 'unknown revision'}) return self.json({'Status': 'OK', 'Node': nodeObj(n)}) def authenticated_post(self): # Require auth with the master key, not a per-builder key. if self.request.get('builder'): self.response.set_status(403) return node = self.request.get('node') date = parseDate(self.request.get('date')) user = self.request.get('user').encode('utf8') desc = self.request.get('desc').encode('utf8') parenthash = self.request.get('parent') if not validNode(node) or not validNode(parenthash) or date is None: return self.json({'Status': 'FAIL', 'Error': 'malformed node, parent, or date'}) n = nodeByHash(node) if n is None: p = nodeByHash(parenthash) if p is None: return self.json({'Status': 'FAIL', 'Error': 'unknown parent'}) # Want to create new node in a transaction so that multiple # requests creating it do not collide and so that multiple requests # creating different nodes get different sequence numbers. # All queries within a transaction must include an ancestor, # but the original datastore objects we used for the dashboard # have no common ancestor. Instead, we use a well-known # root node - the last one before we switched to entity groups - # as the as the common ancestor. root = Commit.get_by_key_name(RootCommitKeyName) def add_commit(): if nodeByHash(node, ancestor=root) is not None: return # Determine number for this commit. # Once we have created one new entry it will be lastRooted.num+1, # but the very first commit created in this scheme will have to use # last.num's number instead (last is likely not rooted). q = Commit.all() q.order('-__key__') q.ancestor(root) last = q.fetch(1)[0] num = last.num+1 n = Commit(key_name = '%08x-%s' % (num, node), parent = root) n.num = num n.node = node n.parentnode = parenthash n.user = user n.date = date n.desc = desc n.put() db.run_in_transaction(add_commit) n = nodeByHash(node) if n is None: return self.json({'Status': 'FAIL', 'Error': 'failed to create commit node'}) return self.json({'Status': 'OK', 'Node': nodeObj(n)}) # Build serves /build. # A POST to /build records a new build result. class Build(webapp.RequestHandler): def post(self): if not auth(self.request): self.response.set_status(403) return builder = self.request.get('builder') log = self.request.get('log').encode('utf-8') loghash = '' if len(log) > 0: loghash = hashlib.sha256(log).hexdigest() l = CompressedLog(key_name=loghash) l.log = bz2.compress(log) l.put() node = self.request.get('node') if not validNode(node): logging.error('Invalid node %s' % (node)) self.response.set_status(500) return n = nodeByHash(node) if n is None: logging.error('Cannot find node %s' % (node)) self.response.set_status(404) return nn = n def add_build(): n = nodeByHash(node, ancestor=nn) if n is None: logging.error('Cannot find hash in add_build: %s %s' % (builder, node)) return s = '%s`%s' % (builder, loghash) for i, b in enumerate(n.builds): if b.split('`', 1)[0] == builder: # logging.error('Found result for %s %s already' % (builder, node)) n.builds[i] = s break else: # logging.error('Added result for %s %s' % (builder, node)) n.builds.append(s) n.put() db.run_in_transaction(add_build) key = 'todo-%s' % builder memcache.delete(key) c = getBrokenCommit(node, builder) if c is not None and not c.fail_notification_sent: notifyBroken(c, builder) self.response.set_status(200) def getBrokenCommit(node, builder): """ getBrokenCommit returns a Commit that breaks the build. The Commit will be either the one specified by node or the one after. """ # Squelch mail if already fixed. head = firstResult(builder) if broken(head, builder) == False: return # Get current node and node before, after. cur = nodeByHash(node) if cur is None: return before = nodeBefore(cur) after = nodeAfter(cur) if broken(before, builder) == False and broken(cur, builder): return cur if broken(cur, builder) == False and broken(after, builder): return after return def firstResult(builder): q = Commit.all().order('-__key__').limit(20) for c in q: for i, b in enumerate(c.builds): p = b.split('`', 1) if p[0] == builder: return c return None def nodeBefore(c): return nodeByHash(c.parentnode) def nodeAfter(c): return Commit.all().filter('parenthash', c.node).get() def notifyBroken(c, builder): def send(): n = Commit.get_by_key_name('%08x-%s' % (c.num, c.node)) if n.fail_notification_sent: return False n.fail_notification_sent = True return n.put() if not db.run_in_transaction(send): return subject = const.mail_fail_subject % (builder, c.desc.split('\n')[0]) path = os.path.join(os.path.dirname(__file__), 'fail-notify.txt') body = template.render(path, { "builder": builder, "node": c.node, "user": c.user, "desc": c.desc, "loghash": logHash(c, builder) }) mail.send_mail( sender=const.mail_from, to=const.mail_fail_to, subject=subject, body=body ) def logHash(c, builder): for i, b in enumerate(c.builds): p = b.split('`', 1) if p[0] == builder: return p[1] return "" def broken(c, builder): """ broken returns True if commit c breaks the build for the specified builder, False if it is a good build, and None if no results exist for this builder. """ if c is None: return None for i, b in enumerate(c.builds): p = b.split('`', 1) if p[0] == builder: return len(p[1]) > 0 return None def node(num): q = Commit.all() q.filter('num =', num) n = q.get() return n def nodeByHash(hash, ancestor=None): q = Commit.all() q.filter('node =', hash) if ancestor is not None: q.ancestor(ancestor) n = q.get() return n # nodeObj returns a JSON object (ready to be passed to simplejson.dump) describing node. def nodeObj(n): return { 'Hash': n.node, 'ParentHash': n.parentnode, 'User': n.user, 'Date': n.date.strftime('%Y-%m-%d %H:%M %z'), 'Desc': n.desc, } class FixedOffset(datetime.tzinfo): """Fixed offset in minutes east from UTC.""" def __init__(self, offset): self.__offset = datetime.timedelta(seconds = offset) def utcoffset(self, dt): return self.__offset def tzname(self, dt): return None def dst(self, dt): return datetime.timedelta(0) def validNode(node): if len(node) != 40: return False for x in node: o = ord(x) if (o < ord('0') or o > ord('9')) and (o < ord('a') or o > ord('f')): return False return True def parseDate(date): if '-' in date: (a, offset) = date.split('-', 1) try: return datetime.datetime.fromtimestamp(float(a), FixedOffset(0-int(offset))) except ValueError: return None if '+' in date: (a, offset) = date.split('+', 1) try: return datetime.datetime.fromtimestamp(float(a), FixedOffset(int(offset))) except ValueError: return None try: return datetime.datetime.utcfromtimestamp(float(date)) except ValueError: return None email_re = re.compile('^[^<]+<([^>]*)>$') def toUsername(user): r = email_re.match(user) if r is None: return user email = r.groups()[0] return email.replace('@golang.org', '') def dateToShortStr(d): return d.strftime('%a %b %d %H:%M') def parseBuild(build): [builder, logblob] = build.split('`') return {'builder': builder, 'log': logblob, 'ok': len(logblob) == 0} def nodeInfo(c): return { "node": c.node, "user": toUsername(c.user), "date": dateToShortStr(c.date), "desc": c.desc, "shortdesc": c.desc.split('\n', 2)[0] } def toRev(c): b = nodeInfo(c) b['builds'] = [parseBuild(build) for build in c.builds] return b def byBuilder(x, y): return cmp(x['builder'], y['builder']) # Give old builders work; otherwise they pound on the web site. class Hwget(DashboardHandler): def get(self): self.response.out.write("8000\n") # This is the URL map for the server. The first three entries are public, the # rest are only used by the builders. application = webapp.WSGIApplication( [('/', MainPage), ('/hw-get', Hwget), ('/log/.*', LogHandler), ('/commit', CommitHandler), ('/init', Init), ('/todo', Todo), ('/build', Build), ], debug=True) def main(): run_wsgi_app(application) if __name__ == "__main__": main()