changeset 516:2f3b7c17d3c3

Switch to storing log messages in files rather than in database rows: * Levels are supported in a supplementary file for now * Upgrading is performed but the old `bitten_log_message` table is not dropped * A message is printed out on upgrade to encourage checking of permissions Fixes http://bitten.edgewall.org/ticket/329
author dfraser
date Fri, 13 Mar 2009 08:52:47 +0000
parents 05cfbe90237e
children 6e38de6858c0
files bitten/admin.py bitten/master.py bitten/model.py bitten/templates/bitten_admin_master.html bitten/tests/admin.py bitten/tests/master.py bitten/tests/model.py bitten/upgrades.py
diffstat 8 files changed, 168 insertions(+), 58 deletions(-) [+]
line wrap: on
line diff
--- a/bitten/admin.py
+++ b/bitten/admin.py
@@ -71,6 +71,11 @@
             self.config['bitten'].set('slave_timeout', str(slave_timeout))
             changed = True
 
+        logs_dir = req.args.get('logs_dir', None)
+        if logs_dir != master.logs_dir:
+            self.config['bitten'].set('logs_dir', str(logs_dir))
+            changed = True
+
         if changed:
             self.config.save()
 
--- a/bitten/master.py
+++ b/bitten/master.py
@@ -14,7 +14,7 @@
 import re
 import time
 
-from trac.config import BoolOption, IntOption
+from trac.config import BoolOption, IntOption, PathOption
 from trac.core import *
 from trac.web import IRequestHandler, HTTPBadRequest, HTTPConflict, \
                      HTTPForbidden, HTTPMethodNotAllowed, HTTPNotFound, \
@@ -55,6 +55,9 @@
         """The time in seconds after which a build is cancelled if the slave
         does not report progress.""")
 
+    logs_dir = PathOption('bitten', 'logs_dir', "log/bitten", doc=
+         """The directory on the server in which client log files will be stored.""")
+
     # IRequestHandler methods
 
     def match_request(self, req):
--- a/bitten/model.py
+++ b/bitten/model.py
@@ -11,6 +11,7 @@
 """Model classes for objects persisted in the database."""
 
 from trac.db import Table, Column, Index
+import os
 
 __docformat__ = 'restructuredtext en'
 
@@ -669,12 +670,9 @@
         Table('bitten_log', key='id')[
             Column('id', auto_increment=True), Column('build', type='int'),
             Column('step'), Column('generator'), Column('orderno', type='int'),
+            Column('filename'),
             Index(['build', 'step'])
         ],
-        Table('bitten_log_message', key=('log', 'line'))[
-            Column('log', type='int'), Column('line', type='int'),
-            Column('level', size=1), Column('message')
-        ]
     ]
 
     # Message levels
@@ -682,9 +680,10 @@
     INFO = 'I'
     WARNING = 'W'
     ERROR = 'E'
+    UNKNOWN = ''
 
     def __init__(self, env, build=None, step=None, generator=None,
-                 orderno=None):
+                 orderno=None, filename=None):
         """Initialize a new build log with the specified attributes.
 
         To actually create this build log in the database, the `insert` method
@@ -696,11 +695,21 @@
         self.step = step
         self.generator = generator or ''
         self.orderno = orderno and int(orderno) or 0
+        self.filename = filename or None
         self.messages = []
+        self.logs_dir = env.config.get('bitten', 'logs_dir', 'log/bitten')
+        if not os.path.isabs(self.logs_dir):
+            self.logs_dir = os.path.join(env.path, self.logs_dir)
 
     exists = property(fget=lambda self: self.id is not None,
                       doc='Whether this build log exists in the database')
 
+    def get_log_file(self, filename):
+        """Returns the full path to the log file"""
+        if filename != os.path.basename(filename):
+            raise ValueError("Filename may not contain path: %s" % (filename,))
+        return os.path.join(self.logs_dir, filename)
+
     def delete(self, db=None):
         """Remove the build log from the database."""
         assert self.exists, 'Cannot delete a non-existing build log'
@@ -711,9 +720,11 @@
             handle_ta = False
 
         cursor = db.cursor()
-        cursor.execute("DELETE FROM bitten_log_message WHERE log=%s",
-                       (self.id,))
+        cursor.execute("SELECT filename FROM bitten_log WHERE id=%s", (self.id,))
+        log_files = cursor.fetchall() or []
         cursor.execute("DELETE FROM bitten_log WHERE id=%s", (self.id,))
+        for log_file in log_files:
+            os.remove(self.get_log_file(log_file[0]))
 
         if handle_ta:
             db.commit()
@@ -734,11 +745,13 @@
                        "VALUES (%s,%s,%s,%s)", (self.build, self.step,
                        self.generator, self.orderno))
         id = db.get_last_id(cursor, 'bitten_log')
+        log_file = "%s.log" % (id,)
+        cursor.execute("UPDATE bitten_log SET filename=%s WHERE id=%s", (log_file, id))
         if self.messages:
-            cursor.executemany("INSERT INTO bitten_log_message "
-                               "(log,line,level,message) VALUES (%s,%s,%s,%s)",
-                               [(id, idx, msg[0], msg[1]) for idx, msg in
-                                enumerate(self.messages)])
+            log_file_name = self.get_log_file(log_file)
+            level_file_name = log_file_name + ".levels"
+            open(log_file_name, "w").writelines([msg[1]+"\n" for msg in self.messages])
+            open(level_file_name, "w").writelines([msg[0]+"\n" for msg in self.messages])
 
         if handle_ta:
             db.commit()
@@ -750,16 +763,27 @@
             db = env.get_db_cnx()
 
         cursor = db.cursor()
-        cursor.execute("SELECT build,step,generator,orderno FROM bitten_log "
+        cursor.execute("SELECT build,step,generator,orderno,filename FROM bitten_log "
                        "WHERE id=%s", (id,))
         row = cursor.fetchone()
         if not row:
             return None
-        log = BuildLog(env, int(row[0]), row[1], row[2], row[3])
+        log = BuildLog(env, int(row[0]), row[1], row[2], row[3], row[4])
         log.id = id
-        cursor.execute("SELECT level,message FROM bitten_log_message "
-                       "WHERE log=%s ORDER BY line", (id,))
-        log.messages = cursor.fetchall() or []
+        if log.filename:
+            log_filename = log.get_log_file(log.filename)
+            if os.path.exists(log_filename):
+                log_lines = open(log_filename, "r").readlines()
+            else:
+                log_lines = []
+            level_filename = log.filename + ".levels"
+            if os.path.exists(level_filename):
+                log_levels = dict(enumerate(open(log.get_log_file(level_filename), "r").readlines()))
+            else:
+                log_levels = {}
+            log.messages = [(log_levels.get(line_num, BuildLog.UNKNOWN), line.rstrip("\n")) for line_num, line in enumerate(log_lines)]
+        else:
+            log.messages = []
 
         return log
 
@@ -937,4 +961,4 @@
 
 schema = BuildConfig._schema + TargetPlatform._schema + Build._schema + \
          BuildStep._schema + BuildLog._schema + Report._schema
-schema_version = 7
+schema_version = 8
--- a/bitten/templates/bitten_admin_master.html
+++ b/bitten/templates/bitten_admin_master.html
@@ -63,6 +63,16 @@
           is considered aborted, in case there has been no activity from
           that slave in that time.
         </p>
+        <div class="field">
+          <label>
+            Directory for storing log files:
+            <input type="text" id="logs_dir" name="logs_dir"
+                   value="$master.logs_dir" size="100" />
+          </label>
+        </div>
+        <p class="hint">
+          The directory on the server in which client log files will be stored.
+        </p>
       </fieldset>
 
       <div class="buttons">
--- a/bitten/tests/admin.py
+++ b/bitten/tests/admin.py
@@ -90,6 +90,7 @@
         self.assertEqual(0, master.stabilize_wait)
         assert not master.adjust_timestamps
         assert not master.build_all
+        self.assertEqual('log/bitten', master.logs_dir)
 
     def test_process_config_changes(self):
         redirected_to = []
--- a/bitten/tests/master.py
+++ b/bitten/tests/master.py
@@ -364,8 +364,8 @@
             self.assertEqual('http://bitten.cmlenz.net/tools/python#unittest',
                              logs[0].generator)
             self.assertEqual(2, len(logs[0].messages))
-            self.assertEqual((u'info', u'Doing stuff'), logs[0].messages[0])
-            self.assertEqual((u'error', u'Ouch that hurt'), logs[0].messages[1])
+            self.assertEqual((u'', u'Doing stuff'), logs[0].messages[0])
+            self.assertEqual((u'', u'Ouch that hurt'), logs[0].messages[1])
 
     def test_process_build_step_success_with_report(self):
         recipe = """<build>
--- a/bitten/tests/model.py
+++ b/bitten/tests/model.py
@@ -14,6 +14,7 @@
 from trac.test import EnvironmentStub
 from bitten.model import BuildConfig, TargetPlatform, Build, BuildStep, \
                          BuildLog, Report, schema
+import os
 
 
 class BuildConfigTestCase(unittest.TestCase):
@@ -446,7 +447,10 @@
         self.assertEqual([], log.messages)
 
     def test_insert(self):
-        log = BuildLog(self.env, build=1, step='test', generator='distutils')
+        log = BuildLog(self.env, build=1, step='test', generator='distutils', filename='1.log')
+        full_file = log.get_log_file('1.log')
+        if os.path.exists(full_file):
+            os.remove(full_file)
         log.messages = [
             (BuildLog.INFO, 'running tests'),
             (BuildLog.ERROR, 'tests failed')
@@ -456,28 +460,33 @@
 
         db = self.env.get_db_cnx()
         cursor = db.cursor()
-        cursor.execute("SELECT build,step,generator FROM bitten_log "
+        cursor.execute("SELECT build,step,generator,filename FROM bitten_log "
                        "WHERE id=%s", (log.id,))
-        self.assertEqual((1, 'test', 'distutils'), cursor.fetchone())
-        cursor.execute("SELECT level,message FROM bitten_log_message "
-                       "WHERE log=%s ORDER BY line", (log.id,))
-        self.assertEqual((BuildLog.INFO, 'running tests'), cursor.fetchone())
-        self.assertEqual((BuildLog.ERROR, 'tests failed'), cursor.fetchone())
+        self.assertEqual((1, 'test', 'distutils', '1.log'), cursor.fetchone())
+        lines = open(full_file, "r").readlines()
+        self.assertEqual('running tests\n', lines[0])
+        self.assertEqual('tests failed\n', lines[1])
+        if os.path.exists(full_file):
+            os.remove(full_file)
 
     def test_insert_empty(self):
-        log = BuildLog(self.env, build=1, step='test', generator='distutils')
+        log = BuildLog(self.env, build=1, step='test', generator='distutils', filename="1.log")
+        full_file = log.get_log_file('1.log')
+        if os.path.exists(full_file):
+            os.remove(full_file)
         log.messages = []
         log.insert()
         self.assertNotEqual(None, log.id)
 
         db = self.env.get_db_cnx()
         cursor = db.cursor()
-        cursor.execute("SELECT build,step,generator FROM bitten_log "
+        cursor.execute("SELECT build,step,generator,filename FROM bitten_log "
                        "WHERE id=%s", (log.id,))
-        self.assertEqual((1, 'test', 'distutils'), cursor.fetchone())
-        cursor.execute("SELECT COUNT(*) FROM bitten_log_message "
-                       "WHERE log=%s", (log.id,))
-        self.assertEqual(0, cursor.fetchone()[0])
+        self.assertEqual((1, 'test', 'distutils', '1.log'), cursor.fetchone())
+        file_exists = os.path.exists(full_file)
+        if file_exists:
+            os.remove(full_file)
+        assert not file_exists
 
     def test_insert_no_build_or_step(self):
         log = BuildLog(self.env, step='test')
@@ -489,13 +498,12 @@
     def test_delete(self):
         db = self.env.get_db_cnx()
         cursor = db.cursor()
-        cursor.execute("INSERT INTO bitten_log (build,step,generator) "
-                       "VALUES (%s,%s,%s)", (1, 'test', 'distutils'))
+        cursor.execute("INSERT INTO bitten_log (build,step,generator,filename) "
+                       "VALUES (%s,%s,%s,%s)", (1, 'test', 'distutils', '1.log'))
         id = db.get_last_id(cursor, 'bitten_log')
-        cursor.executemany("INSERT INTO bitten_log_message "
-                           "VALUES (%s,%s,%s,%s)",
-                           [(id, 1, BuildLog.INFO, 'running tests'),
-                            (id, 2, BuildLog.ERROR, 'tests failed')])
+        logs_dir = self.env.config.get("bitten", "logsdir", "log/bitten")
+        full_file = os.path.join(logs_dir, "1.log")
+        open(full_file, "w").writelines(["running tests\n", "tests failed\n"])
 
         log = BuildLog.fetch(self.env, id=id, db=db)
         self.assertEqual(True, log.exists)
@@ -504,8 +512,10 @@
 
         cursor.execute("SELECT * FROM bitten_log WHERE id=%s", (id,))
         self.assertEqual(True, not cursor.fetchall())
-        cursor.execute("SELECT * FROM bitten_log_message WHERE log=%s", (id,))
-        self.assertEqual(True, not cursor.fetchall())
+        file_exists = os.path.exists(full_file)
+        if os.path.exists(full_file):
+            os.remove(full_file)
+        assert not file_exists
 
     def test_delete_new(self):
         log = BuildLog(self.env, build=1, step='test', generator='foo')
@@ -514,13 +524,12 @@
     def test_fetch(self):
         db = self.env.get_db_cnx()
         cursor = db.cursor()
-        cursor.execute("INSERT INTO bitten_log (build,step,generator) "
-                       "VALUES (%s,%s,%s)", (1, 'test', 'distutils'))
+        cursor.execute("INSERT INTO bitten_log (build,step,generator,filename) "
+                       "VALUES (%s,%s,%s,%s)", (1, 'test', 'distutils', '1.log'))
         id = db.get_last_id(cursor, 'bitten_log')
-        cursor.executemany("INSERT INTO bitten_log_message "
-                           "VALUES (%s,%s,%s,%s)",
-                           [(id, 1, BuildLog.INFO, 'running tests'),
-                            (id, 2, BuildLog.ERROR, 'tests failed')])
+        logs_dir = self.env.config.get("bitten", "logsdir", "log/bitten")
+        full_file = os.path.join(logs_dir, "1.log")
+        open(full_file, "w").writelines(["running tests\n", "tests failed\n"])
 
         log = BuildLog.fetch(self.env, id=id, db=db)
         self.assertEqual(True, log.exists)
@@ -528,19 +537,20 @@
         self.assertEqual(1, log.build)
         self.assertEqual('test', log.step)
         self.assertEqual('distutils', log.generator)
-        self.assertEqual((BuildLog.INFO, 'running tests'), log.messages[0])
-        self.assertEqual((BuildLog.ERROR, 'tests failed'), log.messages[1])
+        self.assertEqual((BuildLog.UNKNOWN, 'running tests'), log.messages[0])
+        self.assertEqual((BuildLog.UNKNOWN, 'tests failed'), log.messages[1])
+        if os.path.exists(full_file):
+            os.remove(full_file)
 
     def test_select(self):
         db = self.env.get_db_cnx()
         cursor = db.cursor()
-        cursor.execute("INSERT INTO bitten_log (build,step,generator) "
-                       "VALUES (%s,%s,%s)", (1, 'test', 'distutils'))
+        cursor.execute("INSERT INTO bitten_log (build,step,generator,filename) "
+                       "VALUES (%s,%s,%s,%s)", (1, 'test', 'distutils', '1.log'))
         id = db.get_last_id(cursor, 'bitten_log')
-        cursor.executemany("INSERT INTO bitten_log_message "
-                           "VALUES (%s,%s,%s,%s)",
-                           [(id, 1, BuildLog.INFO, 'running tests'),
-                            (id, 2, BuildLog.ERROR, 'tests failed')])
+        logs_dir = self.env.config.get("bitten", "logsdir", "log/bitten")
+        full_file = os.path.join(logs_dir, "1.log")
+        open(full_file, "w").writelines(["running tests\n", "tests failed\n"])
 
         logs = BuildLog.select(self.env, build=1, step='test', db=db)
         log = logs.next()
@@ -549,9 +559,11 @@
         self.assertEqual(1, log.build)
         self.assertEqual('test', log.step)
         self.assertEqual('distutils', log.generator)
-        self.assertEqual((BuildLog.INFO, 'running tests'), log.messages[0])
-        self.assertEqual((BuildLog.ERROR, 'tests failed'), log.messages[1])
+        self.assertEqual((BuildLog.UNKNOWN, 'running tests'), log.messages[0])
+        self.assertEqual((BuildLog.UNKNOWN, 'tests failed'), log.messages[1])
         self.assertRaises(StopIteration, logs.next)
+        if os.path.exists(full_file):
+            os.remove(full_file)
 
 
 class ReportTestCase(unittest.TestCase):
--- a/bitten/upgrades.py
+++ b/bitten/upgrades.py
@@ -281,11 +281,66 @@
     for stmt in connector.to_sql(table):
         cursor.execute(stmt)
 
+def add_filename_to_logs(env, db):
+    """Add filename column to log table to save where log files are stored."""
+    from bitten.model import BuildLog
+    cursor = db.cursor()
+
+    cursor.execute("CREATE TEMP TABLE old_log AS "
+                   "SELECT * FROM bitten_log")
+    cursor.execute("DROP TABLE bitten_log")
+
+    connector, _ = DatabaseManager(env)._get_connector()
+    for stmt in connector.to_sql(BuildLog._schema[0]):
+        cursor.execute(stmt)
+
+    cursor.execute("INSERT INTO bitten_log (id,build,step,generator,orderno,filename) "
+                   "SELECT id,build,step,generator,orderno,'' FROM old_log")
+
+def migrate_logs_to_files(env, db):
+    """Migrates logs that are stored in the bitten_log_messages table into files."""
+    from trac.db import Table, Column
+    from bitten.model import BuildLog
+    log_table = BuildLog._schema[0]
+    logs_dir = env.config.get("bitten", "logs_dir", "log/bitten")
+    if not os.path.isabs(logs_dir):
+        logs_dir = os.path.join(env.path, logs_dir)
+    if not os.path.exists(logs_dir):
+        os.mkdir(logs_dir)
+
+    message_table = Table('bitten_log_message', key=('log', 'line'))[
+            Column('log', type='int'), Column('line', type='int'),
+            Column('level', size=1), Column('message')
+        ]
+
+    cursor = db.cursor()
+    message_cursor = db.cursor()
+    update_cursor = db.cursor()
+    cursor.execute("SELECT id FROM bitten_log")
+    for log_id, in cursor.fetchall():
+        filename = "%s.log" % (log_id,)
+        message_cursor.execute("SELECT message, level FROM bitten_log_message WHERE log=%s ORDER BY line", (log_id,))
+        full_filename = os.path.join(logs_dir, filename)
+        message_file = open(full_filename, "w")
+        level_file = open(full_filename+".level", "w")
+        for message, level in message_cursor.fetchall() or []:
+            message_file.write(message + "\n")
+            level_file.write(level + "\n")
+        message_file.close()
+        level_file.close()
+        update_cursor.execute("UPDATE bitten_log SET filename=%s WHERE id=%s", (filename, log_id))
+        env.log.info("Migrated log %s", log_id)
+    env.log_warning("Logs have been migrated from the database to files in %s. "
+        "Ensure permissions are set correctly on this file. "
+        "When you have confirmed that the migration worked correctly, "
+        "you can drop the bitten_log_message table in the database (it remains as a backup)", logs_dir)
+
 map = {
     2: [add_log_table],
     3: [add_recipe_to_config],
     4: [add_config_to_reports],
     5: [add_order_to_log, add_report_tables, xmldb_to_db],
     6: [normalize_file_paths, fixup_generators],
-    7: [add_error_table]
+    7: [add_error_table],
+    8: [add_filename_to_logs,migrate_logs_to_files],
 }
Copyright (C) 2012-2017 Edgewall Software