Move compare function to BuildStatus class
[build-farm.git] / buildfarm / data.py
1 #!/usr/bin/python
2 # Simple database query script for the buildfarm
3 #
4 # Copyright (C) Andrew Tridgell <tridge@samba.org>     2001-2005
5 # Copyright (C) Andrew Bartlett <abartlet@samba.org>   2001
6 # Copyright (C) Vance Lankhaar  <vance@samba.org>      2002-2005
7 # Copyright (C) Martin Pool <mbp@samba.org>            2001
8 # Copyright (C) Jelmer Vernooij <jelmer@samba.org>         2007-2010
9 #
10 #   This program is free software; you can redistribute it and/or modify
11 #   it under the terms of the GNU General Public License as published by
12 #   the Free Software Foundation; either version 2 of the License, or
13 #   (at your option) any later version.
14 #
15 #   This program is distributed in the hope that it will be useful,
16 #   but WITHOUT ANY WARRANTY; without even the implied warranty of
17 #   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
18 #   GNU General Public License for more details.
19 #
20 #   You should have received a copy of the GNU General Public License
21 #   along with this program; if not, write to the Free Software
22 #   Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA.
23
24
25 import ConfigParser
26 from cStringIO import StringIO
27 import hashlib
28 import os
29 import re
30 import time
31 import util
32
33
34 class BuildStatus(object):
35
36     def __init__(self, stages=None, other_failures=None):
37         if stages is not None:
38             self.stages = stages
39         else:
40             self.stages = []
41         if other_failures is not None:
42             self.other_failures = other_failures
43         else:
44             self.other_failures = set()
45
46     def broken_host(self):
47         if "disk full" in self.other_failures:
48             return True
49         return False
50
51     def _status_tuple(self):
52         return [v for (k, v) in self.stages]
53
54     def regressed_since(self, other):
55         """Check if this build has regressed since another build."""
56         if "disk full" in self.other_failures:
57             return False
58         return cmp(self._status_tuple(), other._status_tuple())
59
60     def cmp(a, b):
61
62         #Give more importance to other failures
63         if len(b.other_failures):
64             return 1
65         if len(a.other_failures):
66             return -1
67
68         la = len(a.stages)
69         lb = len(b.stages)
70         if la > lb:
71             return 1
72         elif lb > la:
73             return -1
74         else:
75             if la == 0:
76                 return 0
77
78             sa = a.stages[-1]
79             sb = b.stages[-1]
80
81             return cmp(sb[1], sa[1])
82
83     def __str__(self):
84         return repr((self.stages, self.other_failures))
85
86
87 def check_dir_exists(kind, path):
88     if not os.path.isdir(path):
89         raise Exception("%s directory %s does not exist" % (kind, path))
90
91
92 def build_status_from_logs(log, err):
93     """get status of build"""
94     test_failures = 0
95     test_successes = 0
96     test_seen = 0
97     ret = BuildStatus()
98
99     stages = []
100
101     for l in log:
102         m = re.match("^([A-Z_]+) STATUS:(\s*\d+)$", l)
103         if m:
104             stages.append((m.group(1), int(m.group(2).strip())))
105             if m.group(1) == "TEST":
106                 test_seen = 1
107             continue
108         m = re.match("^ACTION (PASSED|FAILED):\s+test$", l)
109         if m and not test_seen:
110             if m.group(1) == "PASSED":
111                 stages.append(("TEST", 0))
112             else:
113                 stages.append(("TEST", 1))
114             continue
115
116         if l.startswith("No space left on device"):
117             ret.other_failures.add("disk full")
118             continue
119         if l.startswith("maximum runtime exceeded"):
120             ret.other_failures.add("timeout")
121             continue
122         m = re.match("^(PANIC|INTERNAL ERROR):.*$", l)
123         if m:
124             ret.other_failures.add("panic")
125             continue
126         if l.startswith("testsuite-failure: ") or l.startswith("testsuite-error: "):
127             test_failures += 1
128             continue
129         if l.startswith("testsuite-success: "):
130             test_successes += 1
131             continue
132
133     # Scan err file for specific errors
134     for l in err:
135         if "No space left on device" in l:
136             ret.other_failures.add("disk full")
137
138     stage_results = dict(stages)
139     def map_stage(name, result):
140         if name != "TEST":
141             return (name, result)
142         # TEST is special
143         if test_successes + test_failures == 0:
144             # No granular test output
145             return ("TEST", result)
146         if result == 1 and test_failures == 0:
147             ret.other_failures.add("inconsistent test result")
148             return ("TEST", -1)
149         return ("TEST", test_failures)
150
151     ret.stages = [map_stage(name, result) for (name, result) in stages]
152     return ret
153
154
155 def lcov_extract_percentage(text):
156     m = re.search('\<td class="headerItem".*?\>Code\&nbsp\;covered\:\<\/td\>.*?\n.*?\<td class="headerValue".*?\>([0-9.]+) \%', text)
157     if m:
158         return m.group(1)
159     else:
160         return None
161
162
163 class NoSuchBuildError(Exception):
164     """The build with the specified name does not exist."""
165
166     def __init__(self, tree, host, compiler, rev=None):
167         self.tree = tree
168         self.host = host
169         self.compiler = compiler
170         self.rev = rev
171
172
173 class Tree(object):
174     """A tree to build."""
175
176     def __init__(self, name, scm, repo, branch, subdir="", srcdir=""):
177         self.name = name
178         self.repo = repo
179         self.scm = scm
180         self.branch = branch
181         self.subdir = subdir
182         self.srcdir = srcdir
183         self.scm = scm
184
185     def __repr__(self):
186         return "<%s %r>" % (self.__class__.__name__, self.name)
187
188
189 class Build(object):
190     """A single build of a tree on a particular host using a particular compiler.
191     """
192
193     def __init__(self, store, tree, host, compiler, rev=None):
194         self._store = store
195         self.tree = tree
196         self.host = host
197         self.compiler = compiler
198         self.rev = rev
199
200     ###################
201     # the mtime age is used to determine if builds are still happening
202     # on a host.
203     # the ctime age is used to determine when the last real build happened
204
205     def age_mtime(self):
206         """get the age of build from mtime"""
207         file = self._store.build_fname(self.tree, self.host, self.compiler, self.rev)
208
209         st = os.stat("%s.log" % file)
210         return time.time() - st.st_mtime
211
212     def age_ctime(self):
213         """get the age of build from ctime"""
214         file = self._store.build_fname(self.tree, self.host, self.compiler, self.rev)
215
216         st = os.stat("%s.log" % file)
217         return time.time() - st.st_ctime
218
219     def read_log(self):
220         """read full log file"""
221         return open(self._store.build_fname(self.tree, self.host, self.compiler, self.rev)+".log", "r")
222
223     def read_err(self):
224         """read full err file"""
225         try:
226             return open(self._store.build_fname(self.tree, self.host, self.compiler, self.rev)+".err", 'r')
227         except IOError:
228             # No such file
229             return StringIO()
230
231
232     def log_checksum(self):
233         f = self.read_log()
234         try:
235             return hashlib.sha1(f.read()).hexdigest()
236         finally:
237             f.close()
238
239     def revision_details(self):
240         """get the revision of build
241
242         :return: Tuple with revision id and timestamp (if available)
243         """
244         file = self._store.build_fname(self.tree, self.host, self.compiler, self.rev)
245
246         revid = None
247         commit_revid = None
248         timestamp = None
249         f = open("%s.log" % file, 'r')
250         try:
251             for l in f.readlines():
252                 if l.startswith("BUILD COMMIT REVISION: "):
253                     commit_revid = l.split(":", 1)[1].strip()
254                 elif l.startswith("BUILD REVISION: "):
255                     revid = l.split(":", 1)[1].strip()
256                 elif l.startswith("BUILD COMMIT TIME"):
257                     timestamp = l.split(":", 1)[1].strip()
258         finally:
259             f.close()
260
261         return (revid, commit_revid, timestamp)
262
263     def status(self):
264         """get status of build
265
266         :return: tuple with build status
267         """
268         log = self.read_log()
269         try:
270             err = self.read_err()
271             try:
272                 return build_status_from_logs(log, err)
273             finally:
274                 err.close()
275         finally:
276             log.close()
277
278     def err_count(self):
279         """get status of build"""
280         file = self._store.build_fname(self.tree, self.host, self.compiler, self.rev)
281
282         try:
283             err = util.FileLoad("%s.err" % file)
284         except OSError:
285             # File does not exist
286             return 0
287
288         return util.count_lines(err)
289
290
291 class CachingBuild(Build):
292     """Build subclass that caches some of the results that are expensive
293     to calculate."""
294
295     def revision_details(self):
296         file = self._store.build_fname(self.tree, self.host, self.compiler, self.rev)
297         cachef = self._store.cache_fname(self.tree, self.host, self.compiler, self.rev)
298         st1 = os.stat("%s.log" % file)
299
300         try:
301             st2 = os.stat("%s.revision" % cachef)
302         except OSError:
303             # File does not exist
304             st2 = None
305
306         # the ctime/mtime asymmetry is needed so we don't get fooled by
307         # the mtime update from rsync
308         if st2 and st1.st_ctime <= st2.st_mtime:
309             (revid, commit_revid, timestamp) = util.FileLoad("%s.revision" % cachef).split(":", 2)
310             if timestamp == "":
311                 timestamp = None
312             if revid == "":
313                 revid = None
314             if commit_revid == "":
315                 commit_revid = None
316             return (revid, commit_revid, timestamp)
317         (revid, commit_revid, timestamp) = super(CachingBuild, self).revision_details()
318         if not self._store.readonly:
319             util.FileSave("%s.revision" % cachef, "%s:%s:%s" % (revid, commit_revid or "", timestamp or ""))
320         return (revid, commit_revid, timestamp)
321
322     def err_count(self):
323         file = self._store.build_fname(self.tree, self.host, self.compiler, self.rev)
324         cachef = self._store.cache_fname(self.tree, self.host, self.compiler, self.rev)
325         st1 = os.stat("%s.err" % file)
326
327         try:
328             st2 = os.stat("%s.errcount" % cachef)
329         except OSError:
330             # File does not exist
331             st2 = None
332
333         if st2 and st1.st_ctime <= st2.st_mtime:
334             return util.FileLoad("%s.errcount" % cachef)
335
336         ret = super(CachingBuild, self).err_count()
337
338         if not self._store.readonly:
339             util.FileSave("%s.errcount" % cachef, str(ret))
340
341         return ret
342
343     def status(self):
344         file = self._store.build_fname(self.tree, self.host, self.compiler, self.rev)
345         cachefile = self._store.cache_fname(self.tree, self.host, self.compiler, self.rev)+".status"
346
347         st1 = os.stat("%s.log" % file)
348
349         try:
350             st2 = os.stat(cachefile)
351         except OSError:
352             # No such file
353             st2 = None
354
355         if st2 and st1.st_ctime <= st2.st_mtime:
356             return BuildStatus(*eval(util.FileLoad(cachefile)))
357
358         ret = super(CachingBuild, self).status()
359
360         if not self._store.readonly:
361             util.FileSave(cachefile, str(ret))
362
363         return ret
364
365
366 def read_trees_from_conf(path):
367     """Read trees from a configuration file."""
368     ret = {}
369     cfp = ConfigParser.ConfigParser()
370     cfp.readfp(open(path))
371     for s in cfp.sections():
372         ret[s] = Tree(name=s, **dict(cfp.items(s)))
373     return ret
374
375
376 class BuildResultStore(object):
377     """The build farm build result database."""
378
379     OLDAGE = 60*60*4,
380     DEADAGE = 60*60*24*4
381     LCOVHOST = "magni"
382
383     def __init__(self, basedir, readonly=False):
384         """Open the database.
385
386         :param basedir: Build result base directory
387         :param readonly: Whether to avoid saving cache files
388         """
389         self.basedir = basedir
390         check_dir_exists("base", self.basedir)
391         self.readonly = readonly
392
393         self.webdir = os.path.join(basedir, "web")
394         check_dir_exists("web", self.webdir)
395
396         self.datadir = os.path.join(basedir, "data")
397         check_dir_exists("data", self.datadir)
398
399         self.cachedir = os.path.join(basedir, "cache")
400         check_dir_exists("cache", self.cachedir)
401
402         self.lcovdir = os.path.join(basedir, "lcov/data")
403         check_dir_exists("lcov", self.lcovdir)
404
405         self.compilers = util.load_list(os.path.join(self.webdir, "compilers.list"))
406
407         self.trees = read_trees_from_conf(os.path.join(self.webdir, "trees.conf"))
408
409     def get_build(self, tree, host, compiler, rev=None):
410         logf = self.build_fname(tree, host, compiler, rev) + ".log"
411         if not os.path.exists(logf):
412             raise NoSuchBuildError(tree, host, compiler, rev)
413         return CachingBuild(self, tree, host, compiler, rev)
414
415     def cache_fname(self, tree, host, compiler, rev=None):
416         if rev is not None:
417             return os.path.join(self.cachedir, "build.%s.%s.%s-%s" % (tree, host, compiler, rev))
418         else:
419             return os.path.join(self.cachedir, "build.%s.%s.%s" % (tree, host, compiler))
420
421     def build_fname(self, tree, host, compiler, rev=None):
422         """get the name of the build file"""
423         if rev is not None:
424             return os.path.join(self.datadir, "oldrevs/build.%s.%s.%s-%s" % (tree, host, compiler, rev))
425         return os.path.join(self.datadir, "upload/build.%s.%s.%s" % (tree, host, compiler))
426
427     def lcov_status(self, tree):
428         """get status of build"""
429         cachefile = os.path.join(self.cachedir, "lcov.%s.%s.status" % (
430             self.LCOVHOST, tree))
431         file = os.path.join(self.lcovdir, self.LCOVHOST, tree, "index.html")
432         try:
433             st1 = os.stat(file)
434         except OSError:
435             # File does not exist
436             raise NoSuchBuildError(tree, self.LCOVHOST, "lcov")
437         try:
438             st2 = os.stat(cachefile)
439         except OSError:
440             # file does not exist
441             st2 = None
442
443         if st2 and st1.st_ctime <= st2.st_mtime:
444             ret = util.FileLoad(cachefile)
445             if ret == "":
446                 return None
447             return ret
448
449         lcov_html = util.FileLoad(file)
450         perc = lcov_extract_percentage(lcov_html)
451         if perc is None:
452             ret = ""
453         else:
454             ret = perc
455         if self.readonly:
456             util.FileSave(cachefile, ret)
457         return perc
458
459     def get_old_revs(self, tree, host, compiler):
460         """get a list of old builds and their status."""
461         ret = []
462         directory = os.path.join(self.datadir, "oldrevs")
463         logfiles = [d for d in os.listdir(directory) if d.startswith("build.%s.%s.%s-" % (tree, host, compiler)) and d.endswith(".log")]
464         for l in logfiles:
465             m = re.match(".*-([0-9A-Fa-f]+).log$", l)
466             if m:
467                 rev = m.group(1)
468                 stat = os.stat(os.path.join(directory, l))
469                 # skip the current build
470                 if stat.st_nlink == 2:
471                     continue
472                 build = self.get_build(tree, host, compiler, rev)
473                 r = {
474                     "STATUS": build.status(),
475                     "REVISION": rev,
476                     "TIMESTAMP": build.age_ctime(),
477                     }
478                 ret.append(r)
479
480         ret.sort(lambda a, b: cmp(a["TIMESTAMP"], b["TIMESTAMP"]))
481
482         return ret
483
484     def has_host(self, host):
485         for name in os.listdir(os.path.join(self.datadir, "upload")):
486             try:
487                 if name.split(".")[2] == host:
488                     return True
489             except IndexError:
490                 pass
491         return False
492
493     def host_age(self, host):
494         """get the overall age of a host"""
495         # FIXME: Turn this into a simple SQL query, or use something in hostdb ?
496         ret = None
497         for compiler in self.compilers:
498             for tree in self.trees:
499                 try:
500                     build = self.get_build(tree, host, compiler)
501                 except NoSuchBuildError:
502                     pass
503                 else:
504                     ret = min(ret, build.age_mtime())
505         return ret