changeset 47:083e848088ee

* Improvements to the model classes, and a couple of unit tests. * The build master now stores information about ongoing builds in the Trac database. * The web interface displays the status of ongoing builds.
author cmlenz
date Fri, 24 Jun 2005 15:35:23 +0000
parents f7bbf9d2bbe7
children 757aa3bf9594
files bitten/master.py bitten/model.py bitten/slave.py bitten/tests/__init__.py bitten/tests/model.py bitten/trac_ext/web_ui.py bitten/util/tests/beep.py
diffstat 7 files changed, 335 insertions(+), 85 deletions(-) [+]
line wrap: on
line diff
--- a/bitten/master.py
+++ b/bitten/master.py
@@ -20,6 +20,7 @@
 
 import logging
 import os.path
+import time
 
 from trac.env import Environment
 from bitten import __version__ as VERSION
@@ -34,16 +35,23 @@
     def __init__(self, env_path, ip, port):
         beep.Listener.__init__(self, ip, port)
         self.profiles[OrchestrationProfileHandler.URI] = OrchestrationProfileHandler
-
         self.env = Environment(env_path)
+
         self.slaves = {}
+
+        # path to generated snapshot archives, key is (config name, revision)
+        self.snapshots = {}
+
         self.schedule(self.TRIGGER_INTERVAL, self._check_build_triggers)
-        self.build_queue = {}
+
+    def close(self):
+        # Remove all pending builds
+        for build in Build.select(self.env, status=Build.PENDING):
+            build.delete()
+        beep.Listener.close(self)
 
     def _check_build_triggers(self, master, when):
         self.schedule(self.TRIGGER_INTERVAL, self._check_build_triggers)
-        if not self.slaves:
-            return
 
         logging.debug('Checking for build triggers...')
         repos = self.env.get_repository()
@@ -52,36 +60,41 @@
 
             for config in BuildConfig.select(self.env):
                 node = repos.get_node(config.path)
-                if (node.path, node.rev) in self.build_queue:
-                    # Builds already pending
-                    continue
 
                 # Check whether the latest revision of that configuration has
                 # already been built
-                builds = list(Build.select(self.env, node.path, node.rev))
-                if not builds:
-                    logging.info('Enqueuing build of configuration "%s" as of revision [%s]',
-                                 config.name, node.rev)
+                builds = Build.select(self.env, config.name, node.rev)
+                if not list(builds):
                     snapshot = archive.make_archive(self.env, repos, node.path,
                                                     node.rev, config.name)
                     logging.info('Created snapshot archive at %s' % snapshot)
-                    self.build_queue[(node.path, node.rev)] = (config, snapshot)
+                    self.snapshots[(config.name, str(node.rev))] = snapshot
+
+                    logging.info('Enqueuing build of configuration "%s" as of revision [%s]',
+                                 config.name, node.rev)
+                    build = Build(self.env)
+                    build.config = config.name
+                    build.rev = node.rev
+                    build.insert()
         finally:
             repos.close()
 
-        if self.build_queue:
-            self.schedule(5, self._check_build_queue)
+        self.schedule(5, self._check_build_queue)
 
     def _check_build_queue(self, master, when):
-        if self.build_queue:
-            for path, rev in self.build_queue.keys():
-                config, snapshot = self.build_queue[(path, rev)]
-                logging.info('Building configuration "%s" as of revision [%s]',
-                             config.name, rev)
-                for slave in self.slaves.values():
-                    if not slave.building:
-                        slave.send_build(snapshot)
-                        break
+        if not self.slaves:
+            return
+        logging.info('Checking for pending builds...')
+        for build in Build.select(self.env, status=Build.PENDING):
+            logging.info('Building configuration "%s" as of revision [%s]',
+                         build.config, build.rev)
+            snapshot = self.snapshots[(build.config, build.rev)]
+            for slave in self.slaves.values():
+                active_builds = Build.select(self.env, slave=slave.name,
+                                             status=Build.IN_PROGRESS)
+                if not list(active_builds):
+                    slave.send_build(build, snapshot)
+                    break
 
 
 class OrchestrationProfileHandler(beep.ProfileHandler):
@@ -94,11 +107,21 @@
         self.master = self.session.listener
         assert self.master
         self.building = False
-        self.slave_name = None
+        self.name = None
 
     def handle_disconnect(self):
-        del self.master.slaves[self.slave_name]
-        logging.info('Unregistered slave "%s"', self.slave_name)
+        del self.master.slaves[self.name]
+        logging.info('Unregistered slave "%s"', self.name)
+        if self.building:
+            for build in Build.select(self.master.env, slave=self.name,
+                                      status=Build.IN_PROGRESS):
+                logging.info('Build [%s] of "%s" by %s cancelled', build.rev,
+                             build.config, self.name)
+                build.slave = None
+                build.status = Build.PENDING
+                build.time = None
+                build.update()
+                break
 
     def handle_msg(self, msgno, msg):
         assert msg.get_content_type() == beep.BEEP_XML
@@ -114,16 +137,16 @@
                     os_family = child.family
                     os_version = child.version
 
-            self.slave_name = elem.name
-            self.master.slaves[self.slave_name] = self
+            self.name = elem.name
+            self.master.slaves[self.name] = self
 
             xml = xmlio.Element('ok')
             self.channel.send_rpy(msgno, beep.MIMEMessage(xml))
             logging.info('Registered slave "%s" (%s running %s %s [%s])',
-                         self.slave_name, platform, os, os_version, os_family)
+                         self.name, platform, os, os_version, os_family)
 
-    def send_build(self, archive_path, handle_reply=None):
-        logging.info('Initiating build on slave %s', self.slave_name)
+    def send_build(self, build, snapshot_path, handle_reply=None):
+        logging.info('Initiating build on slave %s', self.name)
         self.building = True
 
         def handle_reply(cmd, msgno, msg):
@@ -133,12 +156,18 @@
                     if elem.tagname == 'error':
                         logging.warning('Slave refused build request: %s (%d)',
                                         elem.gettext(), int(elem.code))
+            build.slave = self.name
+            build.time = int(time.time())
+            build.status = Build.IN_PROGRESS
+            build.update()
             logging.info('Build started')
 
-        archive_name = os.path.basename(archive_path)
-        message = beep.MIMEMessage(file(archive_path).read(),
+        # TODO: should not block while reading the file; rather stream it using
+        #       asyncore push_with_producer()
+        snapshot_name = os.path.basename(snapshot_path)
+        message = beep.MIMEMessage(file(snapshot_path).read(),
                                    content_type='application/tar',
-                                   content_disposition=archive_name,
+                                   content_disposition=snapshot_name,
                                    content_encoding='gzip')
         self.channel.send_msg(message, handle_reply=handle_reply)
 
--- a/bitten/model.py
+++ b/bitten/model.py
@@ -69,8 +69,8 @@
         cursor.execute("INSERT INTO bitten_config "
                        "(name,path,label,active,description) "
                        "VALUES (%s,%s,%s,%s,%s)",
-                       (self.name, self.path, self.label, int(self.active or 0),
-                        self.description))
+                       (self.name, self.path, self.label or '',
+                        int(self.active or 0), self.description or ''))
 
         if handle_ta:
             db.commit()
@@ -119,47 +119,63 @@
 class Build(object):
     """Representation of a build."""
 
-    _table = Table('bitten_build', key=('path', 'rev', 'slave'))[
-        Column('rev'), Column('path'), Column('slave'),
+    _table = Table('bitten_build', key=('config', 'rev', 'slave'))[
+        Column('config'), Column('rev'), Column('slave'),
         Column('time', type='int'), Column('duration', type='int'),
-        Column('status', type='int')
+        Column('status', size='1')
     ]
 
-    FAILURE = 'failure'
-    IN_PROGRESS = 'in-progress'
-    SUCCESS = 'success'
+    PENDING = 'P'
+    IN_PROGRESS = 'I'
+    SUCCESS = 'S'
+    FAILURE = 'F'
 
-    def __init__(self, env, path=None, rev=None, slave=None, db=None):
+    def __init__(self, env, config=None, rev=None, slave=None, db=None):
         self.env = env
-        self.rev = self.path = self.slave = None
-        self.time = self.duration = self.status = None
-        if rev:
-            self._fetch(rev, path, slave, db)
+        self.config = self.rev = self.slave = self.time = self.duration = None
+        if config and rev and slave:
+            self._fetch(config, rev, slave, db)
+        else:
+            self.time = self.duration = 0
+            self.status = self.PENDING
 
-    def _fetch(self, rev, path, slave, db=None):
+    def _fetch(self, config, rev, slave, db=None):
         if not db:
             db = self.env.get_db_cnx()
 
         cursor = db.cursor()
         cursor.execute("SELECT time,duration,status FROM bitten_build "
-                       "WHERE rev=%s AND path=%s AND slave=%s",
-                       (rev, path, slave))
+                       "WHERE config=%s AND rev=%s AND slave=%s",
+                       (config, rev, slave))
         row = cursor.fetchone()
         if not row:
             raise Exception, "Build not found"
+        self.config = config
         self.rev = rev
-        self.path = path
         self.slave = slave
         self.time = row[0] and int(row[0])
         self.duration = row[1] and int(row[1])
-        if row[2] is not None:
-            self.status = row[2] and Build.SUCCESS or Build.FAILURE
-        else:
-            self.status = Build.IN_PROGRESS
+        self.status = row[2]
 
     completed = property(fget=lambda self: self.status != Build.IN_PROGRESS)
     successful = property(fget=lambda self: self.status == Build.SUCCESS)
 
+    def delete(self, db=None):
+        if not db:
+            db = self.env.get_db_cnx()
+            handle_ta = True
+        else:
+            handle_ta = False
+
+        assert self.status == self.PENDING, 'Only pending builds can be deleted'
+
+        cursor = db.cursor()
+        cursor.execute("DELETE FROM bitten_build WHERE config=%s AND rev=%s "
+                       "AND slave=%s", (self.config, self.rev,
+                       self.slave or ''))
+        if handle_ta:
+            db.commit()
+
     def insert(self, db=None):
         if not db:
             db = self.env.get_db_cnx()
@@ -167,41 +183,70 @@
         else:
             handle_ta = False
 
+        assert self.config and self.rev
+        assert self.status in (self.PENDING, self.IN_PROGRESS, self.SUCCESS,
+                               self.FAILURE)
+        if not self.slave:
+            assert self.status == self.PENDING
+
         cursor = db.cursor()
         cursor.execute("INSERT INTO bitten_build VALUES (%s,%s,%s,%s,%s,%s)",
-                       (self.rev, self.path, self.slave, self.time,
-                        self.duration, self.status or Build.IN_PROGRESS))
+                       (self.config, self.rev, self.slave or '', self.time or 0,
+                        self.duration or 0, self.status))
+        if handle_ta:
+            db.commit()
 
-    def select(cls, env, path=None, rev=None, slave=None, db=None):
+    def update(self, db=None):
+        if not db:
+            db = self.env.get_db_cnx()
+            handle_ta = True
+        else:
+            handle_ta = False
+
+        assert self.config and self.rev
+        assert self.status in (self.PENDING, self.IN_PROGRESS, self.SUCCESS,
+                               self.FAILURE)
+        if not self.slave:
+            assert self.status == self.PENDING
+
+        cursor = db.cursor()
+        cursor.execute("UPDATE bitten_build SET slave=%s,time=%s,duration=%s,"
+                       "status=%s WHERE config=%s AND rev=%s",
+                       (self.slave or '', self.time or 0, self.duration or 0,
+                        self.status, self.config, self.rev))
+        if handle_ta:
+            db.commit()
+
+    def select(cls, env, config=None, rev=None, slave=None, status=None,
+               db=None):
         if not db:
             db = env.get_db_cnx()
 
         where_clauses = []
+        if config is not None:
+            where_clauses.append(("config=%s", config))
         if rev is not None:
             where_clauses.append(("rev=%s", rev))
-        if path is not None:
-            where_clauses.append(("path=%s", path))
         if slave is not None:
-            where_clauses.append(("slave=%s", path))
+            where_clauses.append(("slave=%s", slave))
+        if status is not None:
+            where_clauses.append(("status=%s", status))
         if where_clauses:
             where = "WHERE " + " AND ".join([wc[0] for wc in where_clauses])
         else:
             where = ""
 
         cursor = db.cursor()
-        cursor.execute("SELECT rev,path,slave,time,duration,status "
-                       "FROM bitten_build " + where,
+        cursor.execute("SELECT config,rev,slave,time,duration,status "
+                       "FROM bitten_build %s ORDER BY config,rev,slave" % where,
                        [wc[1] for wc in where_clauses])
-        for rev, path, slave, time, duration, status in cursor:
+        for config, rev, slave, time, duration, status in cursor:
             build = Build(env)
+            build.config = config
             build.rev = rev
-            build.path = path
             build.slave = slave
-            build.time = time and int(time)
-            build.duration = duration and int(duration)
-            if status is not None:
-                build.status = status and Build.SUCCESS or Build.FAILURE
-            else:
-                build.status = Build.FAILURE
+            build.time = time and int(time) or 0
+            build.duration = duration and int(duration) or 0
+            build.status = status
             yield build
     select = classmethod(select)
--- a/bitten/slave.py
+++ b/bitten/slave.py
@@ -32,8 +32,9 @@
 
     def greeting_received(self, profiles):
         if OrchestrationProfileHandler.URI not in profiles:
-            logging.error('Peer does not support Bitten profile')
-            raise beep.TerminateSession, 'Peer does not support Bitten profile'
+            err = 'Peer does not support the Bitten orchestration profile'
+            logging.error(err)
+            raise beep.TerminateSession, err
         self.channels[0].profile.send_start([OrchestrationProfileHandler])
 
 
@@ -65,18 +66,16 @@
 
     def handle_msg(self, msgno, msg):
         if msg.get_content_type() == 'application/tar':
-            logging.info('Received snapshot')
             workdir = tempfile.mkdtemp(prefix='bitten')
             archive_name = msg.get('Content-Disposition', 'snapshot.tar.gz')
             archive_path = os.path.join(workdir, archive_name)
             file(archive_path, 'wb').write(msg.get_payload())
-            logging.info('Stored snapshot archive at %s', archive_path)
+            logging.info('Received snapshot archive: %s', archive_path)
 
             # TODO: Spawn the build process
 
             xml = xmlio.Element('ok')
             self.channel.send_rpy(msgno, beep.MIMEMessage(xml))
-            logging.info('Sent <ok/> in reply to build request')
 
         else:
             xml = xmlio.Element('error', code=500)['Sorry, what?']
@@ -104,7 +103,7 @@
         try:
             port = int(args[1])
             assert (1 <= port <= 65535), 'port number out of range'
-        except AssertionError, ValueError:
+        except (AssertionError, ValueError):
             parser.error('port must be an integer in the range 1-65535')
     else:
         port = 7633
--- a/bitten/tests/__init__.py
+++ b/bitten/tests/__init__.py
@@ -20,11 +20,12 @@
 
 import unittest
 
-from bitten.tests import recipe
+from bitten.tests import model, recipe
 from bitten.util import tests as util
 
 def suite():
     suite = unittest.TestSuite()
+    suite.addTest(model.suite())
     suite.addTest(recipe.suite())
     suite.addTest(util.suite())
     return suite
new file mode 100644
--- /dev/null
+++ b/bitten/tests/model.py
@@ -0,0 +1,125 @@
+# -*- coding: iso8859-1 -*-
+#
+# Copyright (C) 2005 Christopher Lenz <cmlenz@gmx.de>
+#
+# Bitten is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License as
+# published by the Free Software Foundation; either version 2 of the
+# License, or (at your option) any later version.
+#
+# Trac is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty 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., 675 Mass Ave, Cambridge, MA 02139, USA.
+#
+# Author: Christopher Lenz <cmlenz@gmx.de>
+
+import unittest
+
+from trac.test import EnvironmentStub
+from bitten.model import Build, BuildConfig
+
+
+class BuildConfigTestCase(unittest.TestCase):
+
+    def setUp(self):
+        self.env = EnvironmentStub()
+        db = self.env.get_db_cnx()
+        cursor = db.cursor()
+        cursor.execute(db.to_sql(BuildConfig._table))
+        db.commit()
+
+    def test_new_config(self):
+        config = BuildConfig(self.env)
+        assert not config.exists
+
+    def test_insert_config(self):
+        config = BuildConfig(self.env)
+        config.name = 'test'
+        config.label = 'Test'
+        config.path = 'trunk'
+        config.insert()
+
+        db = self.env.get_db_cnx()
+        cursor = db.cursor()
+        cursor.execute("SELECT name,path,label,active,description "
+                       "FROM bitten_config")
+        self.assertEqual(('test', 'trunk', 'Test', 0, ''), cursor.fetchone())
+
+    def test_insert_config_no_name(self):
+        config = BuildConfig(self.env)
+        self.assertRaises(AssertionError, config.insert)
+
+
+class BuildTestCase(unittest.TestCase):
+
+    def setUp(self):
+        self.env = EnvironmentStub()
+        db = self.env.get_db_cnx()
+        cursor = db.cursor()
+        cursor.execute(db.to_sql(Build._table))
+        db.commit()
+
+    def test_new_build(self):
+        build = Build(self.env)
+        self.assertEqual(Build.PENDING, build.status)
+        self.assertEqual(0, build.time)
+        self.assertEqual(0, build.duration)
+
+    def test_insert_build(self):
+        build = Build(self.env)
+        build.config = 'test'
+        build.rev = '42'
+        build.insert()
+
+        db = self.env.get_db_cnx()
+        cursor = db.cursor()
+        cursor.execute("SELECT config,rev,slave,time,duration,status "
+                       "FROM bitten_build")
+        self.assertEqual(('test', '42', '', 0, 0, 'P'), cursor.fetchone())
+
+    def test_insert_build_no_config_or_rev(self):
+        build = Build(self.env)
+        self.assertRaises(AssertionError, build.insert)
+
+        build = Build(self.env)
+        build.config = 'test'
+        self.assertRaises(AssertionError, build.insert)
+
+        build = Build(self.env)
+        build.rev = '42'
+        self.assertRaises(AssertionError, build.insert)
+
+    def test_insert_build_no_slave(self):
+        build = Build(self.env)
+        build.config = 'test'
+        build.rev = '42'
+        build.status = Build.SUCCESS
+        self.assertRaises(AssertionError, build.insert)
+        build.status = Build.FAILURE
+        self.assertRaises(AssertionError, build.insert)
+        build.status = Build.IN_PROGRESS
+        self.assertRaises(AssertionError, build.insert)
+        build.status = Build.PENDING
+        build.insert()
+
+    def test_insert_invalid_status(self):
+        build = Build(self.env)
+        build.config = 'test'
+        build.rev = '42'
+        build.status = 'DUNNO'
+        self.assertRaises(AssertionError, build.insert)
+
+
+def suite():
+    suite = unittest.TestSuite()
+    suite.addTest(unittest.makeSuite(BuildConfigTestCase, 'test'))
+    suite.addTest(unittest.makeSuite(BuildTestCase, 'test'))
+    return suite
+
+if __name__ == '__main__':
+    unittest.main(defaultTest='suite')
--- a/bitten/trac_ext/web_ui.py
+++ b/bitten/trac_ext/web_ui.py
@@ -19,13 +19,14 @@
 # Author: Christopher Lenz <cmlenz@gmx.de>
 
 import re
+import time
 
 from trac.core import *
-from trac.util import escape
+from trac.util import escape, pretty_timedelta
 from trac.web.chrome import INavigationContributor
 from trac.web.main import IRequestHandler
 from trac.wiki import wiki_to_html
-from bitten.model import BuildConfig
+from bitten.model import Build, BuildConfig
 
 class BuildModule(Component):
 
@@ -88,9 +89,30 @@
    <li>Active: <?cs if:build.config.active ?>yes<?cs else ?>no<?cs /if ?></li>
    <li>Path: <?cs if:build.config.path ?><a href="<?cs
      var:build.config.browser_href ?>"><?cs
-     var:build.config.path ?></a></li><?cs /if ?></ul>
-   <?cs if:build.config.description ?><div class="description"><?cs
-    var:build.config.description ?></div><?cs /if ?>
+     var:build.config.path ?></a></li><?cs /if ?></ul><?cs
+   if:build.config.description ?><div class="description"><?cs
+     var:build.config.description ?></div><?cs /if ?>
+   <div id="builds"><h3>Builds</h3><?cs
+    if:len(build.config.builds) ?><ul><?cs
+     each:b = build.config.builds ?><li><a href="<?cs
+      var:b.href ?>">[<?cs var:b.rev ?>]</a> built by <?cs
+      var:len(b.slaves) ?> slave(s)<?cs
+      if:len(b.slaves) ?>:<ul><?cs
+       each:slave = b.slaves ?><li><strong><?cs var:slave.name ?></strong>: <?cs
+        var:slave.status ?> (started <?cs
+        var:slave.started_delta ?> ago<?cs
+        if:slave.stopped ?>, stopped <?cs
+         var:slave.stopped_delta ?> ago, took <?cs
+         var:slave.duration ?><?cs
+        /if ?>)</li><?cs
+       /each ?>
+      </ul><?cs
+      /if ?>
+      </li><?cs
+     /each ?>
+    </ul><?cs
+   else ?><p>None</p><?cs
+   /if ?></div>
    <div class="buttons">
     <form method="get" action=""><div>
      <input type="hidden" name="action" value="edit" />
@@ -116,7 +138,7 @@
     # IRequestHandler methods
 
     def match_request(self, req):
-        match = re.match(r'/build(?:/(\w+))?(?:/(\w+))?', req.path_info)
+        match = re.match(r'/build(?:/([\w.-]+))?(?:/([\w+.-]))?', req.path_info)
         if match:
             if match.group(1):
                 req.args['config'] = match.group(1)
@@ -206,6 +228,36 @@
             'active': config.active, 'description': description,
             'browser_href': self.env.href.browser(config.path)
         }
+
+        builds = Build.select(self.env, config=config.name)
+        curr_rev = None
+        slave_idx = 0
+        for idx, build in enumerate(builds):
+            if build.rev != curr_rev:
+                slave_idx = 0
+                curr_rev = build.rev
+                req.hdf['build.config.builds.%d' % idx] = {
+                    'rev': build.rev,
+                    'href': self.env.href.changeset(build.rev)
+                }
+            if not build.slave:
+                continue
+            status_label = {Build.PENDING: 'pending',
+                            Build.IN_PROGRESS: 'in progress',
+                            Build.SUCCESS: 'success', Build.FAILURE: 'failed'}
+            prefix = 'build.config.builds.%d.slaves.%d' % (idx, slave_idx)
+            req.hdf[prefix] = {'name': build.slave,
+                               'status': status_label[build.status]}
+            if build.time:
+                started = build.time
+                req.hdf[prefix + '.started'] = time.strftime('%x %X', time.localtime(started))
+                req.hdf[prefix + '.started_delta'] = pretty_timedelta(started)
+            if build.duration:
+                stopped = build.time + build.duration
+                req.hdf[prefix + '.duration'] = pretty_timedelta(stopped, build.time)
+                req.hdf[prefix + '.stopped'] = time.strftime('%x %X', time.localtime(stopped))
+                req.hdf[prefix + '.stopped_delta'] = pretty_timedelta(stopped)
+
         req.hdf['build.mode'] = 'view_config'
 
     def _render_config_form(self, req, config_name=None):
--- a/bitten/util/tests/beep.py
+++ b/bitten/util/tests/beep.py
@@ -530,5 +530,4 @@
     return suite
 
 if __name__ == '__main__':
-    logging.getLogger().setLevel(logging.CRITICAL)
     unittest.main(defaultTest='suite')
Copyright (C) 2012-2017 Edgewall Software