Edgewall Software

source: trunk/bitten/master.py @ 1001

Last change on this file since 1001 was 950, checked in by osimons, 13 years ago

Ensure that master only sends utf-8 bodies (especially errors are prone to contain strings of unknown origin, potentially unicode).

  • Property svn:eol-style set to native
File size: 18.2 KB
CovLine 
1# -*- coding: utf-8 -*-
2#
3# Copyright (C) 2007-2010 Edgewall Software
4# Copyright (C) 2005-2007 Christopher Lenz <cmlenz@gmx.de>
5# All rights reserved.
6#
7# This software is licensed as described in the file COPYING, which
8# you should have received as part of this distribution. The terms
9# are also available at http://bitten.edgewall.org/wiki/License.
10
111"""Build master implementation."""
12
113import calendar
114import re
115import time
116from StringIO import StringIO
17
118from trac.attachment import Attachment
119from trac.config import BoolOption, IntOption, Option
120from trac.core import *
121from trac.resource import ResourceNotFound
122from trac.web import IRequestHandler, RequestDone
23
124from bitten import PROTOCOL_VERSION
125from bitten.model import BuildConfig, Build, BuildStep, BuildLog, Report, \
126                     TargetPlatform
27
128from bitten.main import BuildSystem
129from bitten.queue import BuildQueue
130from bitten.recipe import Recipe
131from bitten.util import xmlio
32
133__all__ = ['BuildMaster']
134__docformat__ = 'restructuredtext en'
35
36
137HTTP_BAD_REQUEST = 400
138HTTP_FORBIDDEN = 403
139HTTP_NOT_FOUND = 404
140HTTP_METHOD_NOT_ALLOWED = 405
141HTTP_CONFLICT = 409
42
43
244class BuildMaster(Component):
145    """Trac request handler implementation for the build master."""
46
147    implements(IRequestHandler)
48
49    # Configuration options
50
151    adjust_timestamps = BoolOption('bitten', 'adjust_timestamps', False, doc=
52        """Whether the timestamps of builds should be adjusted to be close
153        to the timestamps of the corresponding changesets.""")
54
155    build_all = BoolOption('bitten', 'build_all', False, doc=
56        """Whether to request builds of older revisions even if a younger
157        revision has already been built.""")
58
159    stabilize_wait = IntOption('bitten', 'stabilize_wait', 0, doc=
60        """The time in seconds to wait for the repository to stabilize before
61        queuing up a new build.  This allows time for developers to check in
62        a group of related changes back to back without spawning multiple
163        builds.""")
64
165    slave_timeout = IntOption('bitten', 'slave_timeout', 3600, doc=
66        """The time in seconds after which a build is cancelled if the slave
167        does not report progress.""")
68
169    logs_dir = Option('bitten', 'logs_dir', "log/bitten", doc=
170         """The directory on the server in which client log files will be stored.""")
71
172    quick_status = BoolOption('bitten', 'quick_status', False, doc=
73         """Whether to show the current build status within the Trac main
74            navigation bar. '''Note:''' The feature requires expensive database and
75            repository checks for every page request, and should not be enabled
76            if the project has a large repository or uses a non-Subversion
177            repository such as Mercurial or Git.""")
78
179    def __init__(self):
2680        self.env.systeminfo.append(('Bitten',
2681                __import__('bitten', ['__version__']).__version__))
82
83    # IRequestHandler methods
84
185    def match_request(self, req):
2386        match = re.match(r'/builds(?:/(\d+)(?:/(\w+)/([^/]+)?)?)?$',
2387                         req.path_info)
2388        if match:
2389            if match.group(1):
1790                req.args['id'] = match.group(1)
1791                req.args['collection'] = match.group(2)
1792                req.args['member'] = match.group(3)
2393            return True
94
195    def process_request(self, req):
2196        req.perm.assert_permission('BUILD_EXEC')
97
2198        if 'trac_auth' in req.incookie:
2199            slave_token = req.incookie['trac_auth'].value
21100        else:
0101            slave_token = req.session.sid
102
21103        if 'id' not in req.args:
6104            if req.method != 'POST':
1105                self._send_response(req,
1106                                body='Only POST allowed for build creation.')
5107            return self._process_build_creation(req, slave_token)
108
15109        build = Build.fetch(self.env, req.args['id'])
15110        if not build:
1111            self._send_error(req, HTTP_NOT_FOUND,
1112                                  'No such build (%s)' % req.args['id'])
113
14114        build_token = build.slave_info.get('token', '')
14115        if build_token != slave_token:
2116            self._send_error(req, HTTP_CONFLICT,
2117                          'Token mismatch (wrong slave): slave=%s, build=%s' \
2118                               % (slave_token, build_token))
119
12120        config = BuildConfig.fetch(self.env, build.config)
121
12122        if not req.args['collection']:
2123            if req.method == 'DELETE':
1124                return self._process_build_cancellation(req, config, build)
1125            else:
1126                return self._process_build_initiation(req, config, build)
127
10128        if req.method != 'POST':
1129            self._send_error(req, HTTP_METHOD_NOT_ALLOWED,
1130                                  'Method %s not allowed' % req.method)
131
9132        if req.args['collection'] == 'steps':
8133            return self._process_build_step(req, config, build)
1134        elif req.args['collection'] == 'attach':
0135            return self._process_attachment(req, config, build)
1136        elif req.args['collection'] == 'keepalive':
0137            return self._process_keepalive(req, config, build)
0138        else:
1139            self._send_error(req, HTTP_NOT_FOUND,
1140                    "No such collection '%s'" % req.args['collection'])
141
142    # Internal methods
143
1144    def _send_response(self, req, code=200, body='', headers=None):
145        """ Formats and sends the response, raising ``RequestDone``. """
23146        if isinstance(body, unicode):
1147            body = body.encode('utf-8')
23148        req.send_response(code)
23149        headers = headers or {}
23150        headers.setdefault('Content-Length', len(body))
75151        for header in headers:
52152            req.send_header(header, headers[header])
23153        req.write(body)
23154        raise RequestDone
155
1156    def _send_error(self, req, code=500, message=''):
157        """ Formats and sends the error, raising ``RequestDone``. """
9158        headers = {'Content-Type': 'text/plain',
9159                   'Content-Length': str(len(message))}
9160        self._send_response(req, code, body=message, headers=headers)
161
1162    def _process_build_creation(self, req, slave_token):
5163        queue = BuildQueue(self.env, build_all=self.build_all,
5164                           stabilize_wait=self.stabilize_wait,
5165                           timeout=self.slave_timeout)
5166        try:
5167            queue.populate()
0168        except AssertionError, e:
0169            self.log.error(e.message, exc_info=True)
0170            self._send_error(req, HTTP_BAD_REQUEST, e.message)
171
5172        try:
5173            elem = xmlio.parse(req.read())
1174        except xmlio.ParseError, e:
1175            self.log.error('Error parsing build initialization request: %s', e,
1176                           exc_info=True)
1177            self._send_error(req, HTTP_BAD_REQUEST, 'XML parser error')
178
4179        slave_version = int(elem.attr.get('version', 1))
180
181        # FIXME: Remove version compatibility code.
182        # The initial difference between protocol version 3 and 4 is that
183        # the master allows keepalive requests-- the master must be
184        # at least 4 before slaves supporting version 4 are allowed. When
185        # the first force master/slave upgrade requirement comes in
186        # (or we bump the) version number again, remove this code.
4187        if slave_version == 3 and PROTOCOL_VERSION == 4:
0188            self.log.info('Allowing slave version %d to process build for '
0189                          'compatibility. Upgrade slave to support build '
0190                          'keepalives.', slave_version)
4191        elif slave_version != PROTOCOL_VERSION:
2192            self._send_error(req, HTTP_BAD_REQUEST,
2193                    "Master-Slave version mismatch: master=%d, slave=%d" % \
2194                                (PROTOCOL_VERSION, slave_version))
195
2196        slavename = elem.attr['name']
2197        properties = {'name': slavename, Build.IP_ADDRESS: req.remote_addr,
2198                    Build.TOKEN: slave_token}
2199        self.log.info('Build slave %r connected from %s with token %s',
2200                    slavename, req.remote_addr, slave_token)
201
7202        for child in elem.children():
5203            if child.name == 'platform':
2204                properties[Build.MACHINE] = child.gettext()
2205                properties[Build.PROCESSOR] = child.attr.get('processor')
3206            elif child.name == 'os':
2207                properties[Build.OS_NAME] = child.gettext()
2208                properties[Build.OS_FAMILY] = child.attr.get('family')
2209                properties[Build.OS_VERSION] = child.attr.get('version')
1210            elif child.name == 'package':
3211                for name, value in child.attr.items():
2212                    if name == 'name':
1213                        continue
1214                    properties[child.attr['name'] + '.' + name] = value
215
2216        self.log.debug('Build slave configuration: %r', properties)
217
2218        build = queue.get_build_for_slave(slavename, properties)
2219        if not build:
1220            self._send_response(req, 204, '', {})
221
1222        self._send_response(req, 201, 'Build pending', headers={
1223                            'Content-Type': 'text/plain',
1224                            'Location': req.abs_href.builds(build.id)})
225
1226    def _process_build_cancellation(self, req, config, build):
1227        self.log.info('Build slave %r cancelled build %d', build.slave,
1228                      build.id)
1229        build.status = Build.PENDING
1230        build.slave = None
1231        build.slave_info = {}
1232        build.started = 0
1233        db = self.env.get_db_cnx()
1234        for step in list(BuildStep.select(self.env, build=build.id, db=db)):
0235            step.delete(db=db)
1236        build.update(db=db)
237
1238        Attachment.delete_all(self.env, 'build', build.resource.id, db)
239
1240        db.commit()
241
2242        for listener in BuildSystem(self.env).listeners:
1243            listener.build_aborted(build)
244
1245        self._send_response(req, 204, '', {})
246
1247    def _process_build_initiation(self, req, config, build):
1248        self.log.info('Build slave %r initiated build %d', build.slave,
1249                      build.id)
1250        build.started = int(time.time())
1251        build.last_activity = build.started
1252        build.update()
253
2254        for listener in BuildSystem(self.env).listeners:
1255            listener.build_started(build)
256
1257        xml = xmlio.parse(config.recipe)
1258        xml.attr['path'] = config.path
1259        xml.attr['revision'] = build.rev
1260        xml.attr['config'] = config.name
1261        xml.attr['build'] = str(build.id)
1262        target_platform = TargetPlatform.fetch(self.env, build.platform)
1263        xml.attr['platform'] = target_platform.name
1264        xml.attr['name'] = build.slave
1265        xml.attr['form_token'] = req.form_token # For posting attachments
1266        body = str(xml)
267
1268        self.log.info('Build slave %r initiated build %d', build.slave,
1269                      build.id)
270
271        # create the first step, mark it as in-progress.
272
1273        recipe = Recipe(xmlio.parse(config.recipe))
1274        stepname = recipe.__iter__().next().id
275
1276        step = self._start_new_step(build, stepname)
1277        step.insert()
278
1279        self._send_response(req, 200, body, headers={
1280                    'Content-Type': 'application/x-bitten+xml',
1281                    'Content-Length': str(len(body)),
1282                    'Content-Disposition':
1283                        'attachment; filename=recipe_%s_r%s.xml' %
1284                        (config.name, build.rev)})
285
1286    def _process_build_step(self, req, config, build):
8287        try:
8288            elem = xmlio.parse(req.read())
1289        except xmlio.ParseError, e:
1290            self.log.error('Error parsing build step result: %s', e,
1291                           exc_info=True)
1292            self._send_error(req, HTTP_BAD_REQUEST, 'XML parser error')
7293        stepname = elem.attr['step']
294
295        # we should have created this step previously; if it hasn't,
296        # the master and slave are processing steps out of order.
7297        step = BuildStep.fetch(self.env, build=build.id, name=stepname)
7298        if not step:
0299            self._send_error(req, HTTP_CONFLICT, 'Build step has not been created.')
300
7301        recipe = Recipe(xmlio.parse(config.recipe))
7302        index = None
7303        current_step = None
15304        for num, recipe_step in enumerate(recipe):
8305            if recipe_step.id == stepname:
7306                index = num
7307                current_step = recipe_step
7308        if index is None:
0309            self._send_error(req, HTTP_FORBIDDEN,
0310                                'No such build step' % stepname)
7311        last_step = index == num
312
7313        self.log.debug('Slave %s (build %d) completed step %d (%s) with '
7314                       'status %s', build.slave, build.id, index, stepname,
7315                       elem.attr['status'])
316
7317        db = self.env.get_db_cnx()
318
7319        step.stopped = int(time.time())
320
7321        if elem.attr['status'] == 'failure':
3322            self.log.warning('Build %s step %s failed', build.id, stepname)
3323            step.status = BuildStep.FAILURE
3324            if current_step.onerror == 'fail':
1325                last_step = True
1326        else:
4327            step.status = BuildStep.SUCCESS
7328        step.errors += [error.gettext() for error in elem.children('error')]
329
330        # TODO: step.update(db=db)
7331        step.delete(db=db)
7332        step.insert(db=db)
333
334        # Collect log messages from the request body
9335        for idx, log_elem in enumerate(elem.children('log')):
2336            build_log = BuildLog(self.env, build=build.id, step=stepname,
2337                                 generator=log_elem.attr.get('generator'),
2338                                 orderno=idx)
6339            for message_elem in log_elem.children('message'):
4340                build_log.messages.append((message_elem.attr['level'],
4341                                           message_elem.gettext()))
2342            build_log.insert(db=db)
343
344        # Collect report data from the request body
8345        for report_elem in elem.children('report'):
1346            report = Report(self.env, build=build.id, step=stepname,
1347                            category=report_elem.attr.get('category'),
1348                            generator=report_elem.attr.get('generator'))
2349            for item_elem in report_elem.children():
1350                item = {'type': item_elem.name}
1351                item.update(item_elem.attr)
2352                for child_elem in item_elem.children():
1353                    item[child_elem.name] = child_elem.gettext()
1354                report.items.append(item)
1355            report.insert(db=db)
356
357        # If this was the last step in the recipe we mark the build as
358        # completed otherwise just update last_activity
7359        if last_step:
6360            self.log.info('Slave %s completed build %d ("%s" as of [%s])',
6361                          build.slave, build.id, build.config, build.rev)
6362            build.stopped = step.stopped
6363            build.last_activity = build.stopped
364
365            # Determine overall outcome of the build by checking the outcome
366            # of the individual steps against the "onerror" specification of
367            # each step in the recipe
10368            for num, recipe_step in enumerate(recipe):
6369                step = BuildStep.fetch(self.env, build.id, recipe_step.id)
6370                if step.status == BuildStep.FAILURE:
3371                    if recipe_step.onerror == 'fail' or \
2372                            recipe_step.onerror == 'continue':
2373                        build.status = Build.FAILURE
2374                        break
2375            else:
4376                build.status = Build.SUCCESS
377
6378            build.update(db=db)
6379        else:
1380            build.last_activity = step.stopped
1381            build.update(db=db)
382
383            # start the next step.
3384            for num, recipe_step in enumerate(recipe):
2385                if num == index + 1:
1386                    next_step = recipe_step
1387            if next_step is None:
0388                self._send_error(req, HTTP_FORBIDDEN,
0389                                 'Unable to find step after ' % stepname)
390
1391            step = self._start_new_step(build, next_step.id)
1392            step.insert(db=db)
393
7394        db.commit()
395
7396        if last_step:
12397            for listener in BuildSystem(self.env).listeners:
6398                listener.build_completed(build)
399
7400        body = 'Build step processed'
7401        self._send_response(req, 201, body, {
7402                            'Content-Type': 'text/plain',
7403                            'Content-Length': str(len(body)),
7404                            'Location': req.abs_href.builds(
7405                                    build.id, 'steps', stepname)})
406
1407    def _process_attachment(self, req, config, build):
2408        resource_id = req.args['member'] == 'config' \
1409                    and build.config or build.resource.id
2410        upload = req.args['file']
2411        if not upload.file:
0412            send_error(req, message="Attachment not received.")
2413        self.log.debug('Received attachment %s for attaching to build:%s',
2414                      upload.filename, resource_id)
415
416        # Determine size of file
2417        upload.file.seek(0, 2) # to the end
2418        size = upload.file.tell()
2419        upload.file.seek(0)    # beginning again
420
421        # Delete attachment if it already exists
2422        try:
2423            old_attach = Attachment(self.env, 'build',
2424                            parent_id=resource_id, filename=upload.filename)
0425            old_attach.delete()
2426        except ResourceNotFound:
2427            pass
428
429        # Save new attachment
2430        attachment = Attachment(self.env, 'build', parent_id=resource_id)
2431        attachment.description = req.args.get('description', '')
2432        attachment.author = req.authname
2433        attachment.insert(upload.filename, upload.file, size)
434
2435        self._send_response(req, 201, 'Attachment created', headers={
2436                            'Content-Type': 'text/plain',
2437                            'Content-Length': str(len('Attachment created'))})
438
1439    def _process_keepalive(self, req, config, build):
0440        build.last_activity = int(time.time())
0441        build.update()
442
0443        self.log.info('Slave %s build %d keepalive ("%s" as of [%s])',
0444                      build.slave, build.id, build.config, build.rev)
445
0446        body = 'Keepalive processed'
0447        self._send_response(req, 200, body, {
0448                            'Content-Type': 'text/plain',
0449                            'Content-Length': str(len(body))})
450
1451    def _start_new_step(self, build, stepname):
452        """Creates the in-memory representation for a newly started
453        step, ready to be persisted to the database.
454        """
13455        step = BuildStep(self.env, build=build.id, name=stepname)
13456        step.status = BuildStep.IN_PROGRESS
13457        step.started = int(time.time())
13458        step.stopped = 0
459
13460        return step
Note: See TracBrowser for help on using the repository browser.