Edgewall Software

source: trunk/bitten/queue.py @ 1001

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

Make platform rules matching case-insensitive. Fixes #334.

Patch with test by Anatoly Techtonik. Thanks!

  • Property svn:eol-style set to native
File size: 13.8 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
11"""Implements the scheduling of builds for a project.
12
13This module provides the functionality for scheduling builds for a specific
14Trac environment. It is used by both the build master and the web interface to
15get the list of required builds (revisions not built yet).
16
17Furthermore, the `BuildQueue` class is used by the build master to determine
18the next pending build, and to match build slaves against configured target
19platforms.
120"""
21
122from itertools import ifilter
123import re
124import time
25
126from trac.util.datefmt import to_timestamp
127from trac.util import pretty_timedelta, format_datetime
128from trac.attachment import Attachment
29
30
131from bitten.model import BuildConfig, TargetPlatform, Build, BuildStep
32
133__docformat__ = 'restructuredtext en'
34
35
136def collect_changes(repos, config, db=None):
37    """Collect all changes for a build configuration that either have already
38    been built, or still need to be built.
39   
40    This function is a generator that yields ``(platform, rev, build)`` tuples,
41    where ``platform`` is a `TargetPlatform` object, ``rev`` is the identifier
42    of the changeset, and ``build`` is a `Build` object or `None`.
43
44    :param repos: the version control repository
45    :param config: the build configuration
46    :param db: a database connection (optional)
47    """
1448    env = config.env
1449    if not db:
750        db = env.get_db_cnx()
1451    try:
1452        node = repos.get_node(config.path, config.max_rev)
053    except Exception, e:
054        env.log.warn('Error accessing path %r for configuration %r',
055                    config.path, config.name, exc_info=True)
056        return
57
6158    for path, rev, chg in node.get_history():
59
60        # Don't follow moves/copies
5461        if path != repos.normalize_path(config.path):
162            break
63
64        # Stay within the limits of the build config
5365        if config.min_rev and repos.rev_older_than(rev, config.min_rev):
166            break
5267        if config.max_rev and repos.rev_older_than(config.max_rev, rev):
168            continue
69
70        # Make sure the repository directory isn't empty at this
71        # revision
5172        old_node = repos.get_node(path, rev)
5173        is_empty = True
5174        for entry in old_node.get_entries():
5075            is_empty = False
5076            break
5177        if is_empty:
178            continue
79
80        # For every target platform, check whether there's a build
81        # of this revision
10782        for platform in TargetPlatform.select(env, config.name, db=db):
6283            builds = list(Build.select(env, config.name, rev, platform.id,
6284                                       db=db))
6285            if builds:
1686                build = builds[0]
1687            else:
4688                build = None
89
6290            yield platform, rev, build
91
92
293class BuildQueue(object):
94    """Enapsulates the build queue of an environment.
95   
96    A build queue manages the the registration of build slaves and detection of
97    repository revisions that need to be built.
198    """
99
1100    def __init__(self, env, build_all=False, stabilize_wait=0, timeout=0):
101        """Create the build queue.
102       
103        :param env: the Trac environment
104        :param build_all: whether older revisions should be built
105        :param stabilize_wait: The time in seconds to wait before considering
106                        the repository stable to create a build in the queue.
107        :param timeout: the time in seconds after which an in-progress build
108                        should be considered orphaned, and reset to pending
109                        state
110        """
24111        self.env = env
24112        self.log = env.log
24113        self.build_all = build_all
24114        self.stabilize_wait = stabilize_wait
24115        self.timeout = timeout
116
117    # Build scheduling
118
1119    def get_build_for_slave(self, name, properties):
120        """Check whether one of the pending builds can be built by the build
121        slave.
122       
123        :param name: the name of the slave
124        :type name: `basestring`
125        :param properties: the slave configuration
126        :type properties: `dict`
127        :return: the allocated build, or `None` if no build was found
128        :rtype: `Build`
129        """
5130        self.log.debug('Checking for pending builds...')
131
5132        db = self.env.get_db_cnx()
5133        repos = self.env.get_repository()
5134        assert repos, 'No "(default)" Repository: Add a repository or alias ' \
5135                      'named "(default)" to Trac.'
136
5137        self.reset_orphaned_builds()
138
139        # Iterate through pending builds by descending revision timestamp, to
140        # avoid the first configuration/platform getting all the builds
7141        platforms = [p.id for p in self.match_slave(name, properties)]
5142        builds_to_delete = []
5143        build_found = False
7144        for build in Build.select(self.env, status=Build.PENDING, db=db):
4145            if self.should_delete_build(build, repos):
2146                self.log.info('Scheduling build %d for deletion', build.id)
2147                builds_to_delete.append(build)
2148            elif build.platform in platforms:
2149                build_found = True
2150                break
5151        if not build_found:
3152            self.log.debug('No pending builds.')
3153            build = None
154
155        # delete any obsolete builds
7156        for build_to_delete in builds_to_delete:
2157            build_to_delete.delete(db=db)
158
5159        if build:
2160            build.slave = name
2161            build.slave_info.update(properties)
2162            build.status = Build.IN_PROGRESS
2163            build.update(db=db)
164
5165        if build or builds_to_delete:
4166            db.commit()
167
5168        return build
169
1170    def match_slave(self, name, properties):
171        """Match a build slave against available target platforms.
172       
173        :param name: the name of the slave
174        :type name: `basestring`
175        :param properties: the slave configuration
176        :type properties: `dict`
177        :return: the list of platforms the slave matched
178        """
12179        platforms = []
180
22181        for config in BuildConfig.select(self.env):
19182            for platform in TargetPlatform.select(self.env, config=config.name):
9183                match = True
15184                for propname, pattern in ifilter(None, platform.rules):
9185                    try:
9186                        propvalue = properties.get(propname)
9187                        if not propvalue or not re.match(pattern,
9188                                                         propvalue, re.I):
2189                            match = False
2190                            break
1191                    except re.error:
1192                        self.log.error('Invalid platform matching pattern "%s"',
1193                                       pattern, exc_info=True)
1194                        match = False
1195                        break
9196                if match:
6197                    self.log.debug('Slave %r matched target platform %r of '
6198                                   'build configuration %r', name,
6199                                   platform.name, config.name)
6200                    platforms.append(platform)
201
12202        if not platforms:
6203            self.log.warning('Slave %r matched none of the target platforms',
6204                             name)
205
12206        return platforms
207
1208    def populate(self):
209        """Add a build for the next change on each build configuration to the
210        queue.
211
212        The next change is the latest repository check-in for which there isn't
213        a corresponding build on each target platform. Repeatedly calling this
214        method will eventually result in the entire change history of the build
215        configuration being in the build queue.
216        """
12217        repos = self.env.get_repository()
12218        assert repos, 'No "(default)" Repository: Add a repository or alias ' \
12219                      'named "(default)" to Trac.'
220
11221        db = self.env.get_db_cnx()
11222        builds = []
223
18224        for config in BuildConfig.select(self.env, db=db):
7225            platforms = []
32226            for platform, rev, build in collect_changes(repos, config, db):
227
29228                if not self.build_all and platform.id in platforms:
229                    # We've seen this platform already, so these are older
230                    # builds that should only be built if built_all=True
4231                    self.log.debug('Ignoring older revisions for configuration '
4232                                   '%r on %r', config.name, platform.name)
4233                    break
234
25235                platforms.append(platform.id)
236
25237                if build is None:
9238                    self.log.info('Enqueuing build of configuration "%s" at '
9239                                  'revision [%s] on %s', config.name, rev,
9240                                  platform.name)
241
9242                    rev_time = to_timestamp(repos.get_changeset(rev).date)
9243                    age = int(time.time()) - rev_time
9244                    if self.stabilize_wait and age < self.stabilize_wait:
0245                        self.log.info('Delaying build of revision %s until %s '
0246                                      'seconds pass. Current age is: %s '
0247                                      'seconds' % (rev, self.stabilize_wait,
0248                                      age))
0249                        continue
250
9251                    build = Build(self.env, config=config.name,
9252                                  platform=platform.id, rev=str(rev),
9253                                  rev_time=rev_time)
9254                    builds.append(build)
255
20256        for build in builds:
9257            try:
9258                build.insert(db=db)
9259                db.commit()
0260            except Exception, e:
261                # really only want to catch IntegrityErrors raised when
262                # a second slave attempts to add builds with the same
263                # (config, platform, rev) as an existing build.
0264                self.log.info('Failed to insert build of configuration "%s" '
0265                    'at revision [%s] on platform [%s]: %s',
0266                    build.config, build.rev, build.platform, e)
0267                db.rollback()
268
1269    def reset_orphaned_builds(self):
270        """Reset all in-progress builds to ``PENDING`` state if they've been
271        running so long that the configured timeout has been reached.
272       
273        This is used to cleanup after slaves that have unexpectedly cancelled
274        a build without notifying the master, or are for some other reason not
275        reporting back status updates.
276        """
6277        if not self.timeout:
278            # If no timeout is set, none of the in-progress builds can be
279            # considered orphaned
3280            return
281
3282        db = self.env.get_db_cnx()
3283        now = int(time.time())
5284        for build in Build.select(self.env, status=Build.IN_PROGRESS, db=db):
2285            if now - build.last_activity < self.timeout:
286                # This build has not reached the timeout yet, assume it's still
287                # being executed
1288                continue
289
1290            self.log.info('Orphaning build %d. Last activity was %s (%s)' % \
1291                              (build.id, format_datetime(build.last_activity),
1292                               pretty_timedelta(build.last_activity)))
293
1294            build.status = Build.PENDING
1295            build.slave = None
1296            build.slave_info = {}
1297            build.started = 0
1298            build.stopped = 0
1299            build.last_activity = 0
1300            for step in list(BuildStep.select(self.env, build=build.id, db=db)):
0301                step.delete(db=db)
1302            build.update(db=db)
303
1304            Attachment.delete_all(self.env, 'build', build.resource.id, db)
3305        db.commit()
306
1307    def should_delete_build(self, build, repos):
10308        config = BuildConfig.fetch(self.env, build.config)
10309        config_name = config and config.name \
1310                        or 'unknown config "%s"' % build.config
311
10312        platform = TargetPlatform.fetch(self.env, build.platform)
313        # Platform may or may not exist anymore - get safe name for logging
10314        platform_name = platform and platform.name \
2315                        or 'unknown platform "%s"' % build.platform
316
317        # Drop build if platform no longer exists
10318        if not platform:
2319            self.log.info('Dropping build of configuration "%s" at '
2320                     'revision [%s] on %s because the platform no longer '
2321                     'exists', config.name, build.rev, platform_name)
2322            return True
323
324        # Ignore pending builds for deactived build configs
8325        if not (config and config.active):
3326            self.log.info('Dropping build of configuration "%s" at '
3327                     'revision [%s] on %s because the configuration is '
3328                     'deactivated', config_name, build.rev, platform_name)
3329            return True
330
331        # Stay within the revision limits of the build config
5332        if (config.min_rev and repos.rev_older_than(build.rev,
2333                                                    config.min_rev)) \
4334        or (config.max_rev and repos.rev_older_than(config.max_rev,
1335                                                    build.rev)):
2336            self.log.info('Dropping build of configuration "%s" at revision [%s] on '
2337                     '"%s" because it is outside of the revision range of the '
2338                     'configuration', config.name, build.rev, platform_name)
2339            return True
340
341        # If not 'build_all', drop if a more recent revision is available
3342        if not self.build_all and \
3343                len(list(Build.select(self.env, config=build.config,
3344                min_rev_time=build.rev_time, platform=build.platform))) > 1:
1345            self.log.info('Dropping build of configuration "%s" at revision [%s] '
1346                     'on "%s" because a more recent build exists',
1347                         config.name, build.rev, platform_name)
1348            return True
349
2350        return False
Note: See TracBrowser for help on using the repository browser.