Various fixes found during ad-hoc testing.
[amitay/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 from cStringIO import StringIO
25 import collections
26 import hashlib
27 import os
28 import re
29 import time
30 import util
31
32
33 class BuildSummary(object):
34
35     def __init__(self, host, tree, compiler, revision, status):
36         self.host = host
37         self.tree = tree
38         self.compiler = compiler
39         self.revision = revision
40         self.status = status
41
42
43 BuildStageResult = collections.namedtuple("BuildStageResult", "name result")
44
45
46 class BuildStatus(object):
47
48     def __init__(self, stages=None, other_failures=None):
49         if stages is not None:
50             self.stages = stages
51         else:
52             self.stages = []
53         if other_failures is not None:
54             self.other_failures = other_failures
55         else:
56             self.other_failures = set()
57
58     def __str__(self):
59         if self.other_failures:
60             return ",".join(self.other_failures)
61         return "/".join(map(str, self._status_tuple()))
62
63     def broken_host(self):
64         if "disk full" in self.other_failures:
65             return True
66         return False
67
68     def _status_tuple(self):
69         return [sr.result for sr in self.stages]
70
71     def regressed_since(self, other):
72         """Check if this build has regressed since another build."""
73         if "disk full" in self.other_failures:
74             return False
75         return cmp(self._status_tuple(), other._status_tuple())
76
77     def __cmp__(self, other):
78         other_extra = other.other_failures - self.other_failures
79         self_extra = self.other_failures - other.other_failures
80         # Give more importance to other failures
81         if other_extra:
82             return 1
83         if self_extra:
84             return -1
85
86         la = len(self.stages)
87         lb = len(other.stages)
88         if la > lb:
89             return 1
90         elif lb > la:
91             return -1
92         else:
93             return cmp(other.stages, self.stages)
94
95     def __repr__(self):
96         return "%s(%r, %r)" % (self.__class__.__name__, self.stages, self.other_failures)
97
98
99 def check_dir_exists(kind, path):
100     if not os.path.isdir(path):
101         raise Exception("%s directory %s does not exist" % (kind, path))
102
103
104 def build_status_from_logs(log, err):
105     """get status of build"""
106     test_failures = 0
107     test_successes = 0
108     test_seen = 0
109     ret = BuildStatus()
110
111     stages = []
112
113     for l in log:
114         m = re.match("^([A-Z_]+) STATUS:(\s*\d+)$", l)
115         if m:
116             stages.append(BuildStageResult(m.group(1), int(m.group(2).strip())))
117             if m.group(1) == "TEST":
118                 test_seen = 1
119             continue
120         m = re.match("^ACTION (PASSED|FAILED):\s+test$", l)
121         if m and not test_seen:
122             if m.group(1) == "PASSED":
123                 stages.append(BuildStageResult("TEST", 0))
124             else:
125                 stages.append(BuildStageResult("TEST", 1))
126             continue
127
128         if l.startswith("No space left on device"):
129             ret.other_failures.add("disk full")
130             continue
131         if l.startswith("maximum runtime exceeded"):
132             ret.other_failures.add("timeout")
133             continue
134         m = re.match("^(PANIC|INTERNAL ERROR):.*$", l)
135         if m:
136             ret.other_failures.add("panic")
137             continue
138         if l.startswith("testsuite-failure: ") or l.startswith("testsuite-error: "):
139             test_failures += 1
140             continue
141         if l.startswith("testsuite-success: "):
142             test_successes += 1
143             continue
144
145     # Scan err file for specific errors
146     for l in err:
147         if "No space left on device" in l:
148             ret.other_failures.add("disk full")
149
150     def map_stage(sr):
151         if sr.name != "TEST":
152             return sr
153         # TEST is special
154         if test_successes + test_failures == 0:
155             # No granular test output
156             return BuildStageResult("TEST", sr.result)
157         if sr.result == 1 and test_failures == 0:
158             ret.other_failures.add("inconsistent test result")
159             return BuildStageResult("TEST", -1)
160         return BuildStageResult("TEST", test_failures)
161
162     ret.stages = map(map_stage, stages)
163     return ret
164
165
166 class NoSuchBuildError(Exception):
167     """The build with the specified name does not exist."""
168
169     def __init__(self, tree, host, compiler, rev=None):
170         self.tree = tree
171         self.host = host
172         self.compiler = compiler
173         self.rev = rev
174
175
176 class Build(object):
177     """A single build of a tree on a particular host using a particular compiler.
178     """
179
180     def __init__(self, basename, tree, host, compiler, rev=None):
181         self.basename = basename
182         self.tree = tree
183         self.host = host
184         self.compiler = compiler
185         self.commit_revision = self.revision = rev
186
187     def __repr__(self):
188         if self.revision is not None:
189             return "<%s: revision %s of %s on %s using %s>" % (self.__class__.__name__, self.revision, self.tree, self.host, self.compiler)
190         else:
191             return "<%s: %s on %s using %s>" % (self.__class__.__name__, self.tree, self.host, self.compiler)
192
193     def remove_logs(self):
194         os.unlink(self.basename + ".log")
195         if os.path.exists(self.basename+".err"):
196             os.unlink(self.basename+".err")
197
198     def remove(self):
199         self.remove_logs()
200
201     ###################
202     # the mtime age is used to determine if builds are still happening
203     # on a host.
204     # the ctime age is used to determine when the last real build happened
205
206     def age_mtime(self):
207         """get the age of build from mtime"""
208         st = os.stat("%s.log" % self.basename)
209         return time.time() - st.st_mtime
210
211     def age_ctime(self):
212         """get the age of build from ctime"""
213         st = os.stat("%s.log" % self.basename)
214         return time.time() - st.st_ctime
215
216     def read_log(self):
217         """read full log file"""
218         return open(self.basename+".log", "r")
219
220     def read_err(self):
221         """read full err file"""
222         try:
223             return open(self.basename+".err", 'r')
224         except IOError:
225             # No such file
226             return StringIO()
227
228     def log_checksum(self):
229         f = self.read_log()
230         try:
231             return hashlib.sha1(f.read()).hexdigest()
232         finally:
233             f.close()
234
235     def summary(self):
236         (revid, timestamp) = self.revision_details()
237         status = self.status()
238         return BuildSummary(self.host, self.tree, self.compiler, revid, status)
239
240     def revision_details(self):
241         """get the revision of build
242
243         :return: Tuple with revision id and timestamp (if available)
244         """
245         revid = None
246         timestamp = None
247         f = self.read_log()
248         try:
249             for l in f:
250                 if l.startswith("BUILD COMMIT REVISION: "):
251                     revid = l.split(":", 1)[1].strip()
252                 elif l.startswith("BUILD COMMIT TIME"):
253                     timestamp = l.split(":", 1)[1].strip()
254         finally:
255             f.close()
256
257         return (revid, timestamp)
258
259     def status(self):
260         """get status of build
261
262         :return: tuple with build status
263         """
264         log = self.read_log()
265         try:
266             err = self.read_err()
267             try:
268                 return build_status_from_logs(log, err)
269             finally:
270                 err.close()
271         finally:
272             log.close()
273
274     def err_count(self):
275         """get status of build"""
276         file = self.read_err()
277         return len(file.readlines())
278
279
280 class CachingBuild(Build):
281     """Build subclass that caches some of the results that are expensive
282     to calculate."""
283
284     def __init__(self, store, *args, **kwargs):
285         self._store = store
286         super(CachingBuild, self).__init__(*args, **kwargs)
287         if self.revision:
288             self.cache_basename = self._store.cache_fname(self.tree, self.host, self.compiler, self.revision)
289         else:
290             self.cache_basename = self._store.cache_fname(self.tree, self.host, self.compiler)
291
292     def revision_details(self):
293         st1 = os.stat("%s.log" % self.basename)
294
295         try:
296             st2 = os.stat("%s.revision" % self.cache_basename)
297         except OSError:
298             # File does not exist
299             st2 = None
300
301         # the ctime/mtime asymmetry is needed so we don't get fooled by
302         # the mtime update from rsync
303         if st2 and st1.st_ctime <= st2.st_mtime:
304             (revid, timestamp) = util.FileLoad("%s.revision" % self.cache_basename).split(":", 2)
305             if timestamp == "":
306                 timestamp = None
307             if revid == "":
308                 revid = None
309             return (revid, timestamp)
310         (revid, timestamp) = super(CachingBuild, self).revision_details()
311         if not self._store.readonly:
312             util.FileSave("%s.revision" % self.cache_basename, "%s:%s" % (revid, timestamp or ""))
313         return (revid, timestamp)
314
315     def err_count(self):
316         st1 = os.stat("%s.err" % self.basename)
317
318         try:
319             st2 = os.stat("%s.errcount" % self.cache_basename)
320         except OSError:
321             # File does not exist
322             st2 = None
323
324         if st2 and st1.st_ctime <= st2.st_mtime:
325             return util.FileLoad("%s.errcount" % self.cache_basename)
326
327         ret = super(CachingBuild, self).err_count()
328
329         if not self._store.readonly:
330             util.FileSave("%s.errcount" % self.cache_basename, str(ret))
331
332         return ret
333
334     def status(self):
335         cachefile = self.cache_basename + ".status"
336
337         st1 = os.stat("%s.log" % self.basename)
338
339         try:
340             st2 = os.stat(cachefile)
341         except OSError:
342             # No such file
343             st2 = None
344
345         if st2 and st1.st_ctime <= st2.st_mtime:
346             return eval(util.FileLoad(cachefile))
347
348         ret = super(CachingBuild, self).status()
349
350         if not self._store.readonly:
351             util.FileSave(cachefile, repr(ret))
352
353         return ret
354
355
356 class UploadBuildResultStore(object):
357
358     def __init__(self, path):
359         """Open the database.
360
361         :param path: Build result base directory
362         """
363         self.path = path
364
365     def get_new_builds(self):
366         for name in os.listdir(self.path):
367             try:
368                 (build, tree, host, compiler, extension) = name.split(".")
369             except ValueError:
370                 continue
371             if build != "build" or extension != "log":
372                 continue
373             yield self.get_build(tree, host, compiler)
374
375     def build_fname(self, tree, host, compiler):
376         return os.path.join(self.path, "build.%s.%s.%s" % (tree, host, compiler))
377
378     def has_host(self, host):
379         for name in os.listdir(self.path):
380             try:
381                 if name.split(".")[2] == host:
382                     return True
383             except IndexError:
384                 pass
385         return False
386
387     def get_build(self, tree, host, compiler):
388         basename = self.build_fname(tree, host, compiler)
389         logf = "%s.log" % basename
390         if not os.path.exists(logf):
391             raise NoSuchBuildError(tree, host, compiler)
392         return Build(basename, tree, host, compiler)
393
394
395 class CachingUploadBuildResultStore(UploadBuildResultStore):
396
397     def __init__(self, basedir, cachedir, readonly=False):
398         """Open the database.
399
400         :param readonly: Whether to avoid saving cache files
401         """
402         super(CachingUploadBuildResultStore, self).__init__(basedir)
403         self.cachedir = cachedir
404         self.readonly = readonly
405
406     def cache_fname(self, tree, host, compiler):
407         return os.path.join(self.cachedir, "build.%s.%s.%s" % (tree, host, compiler))
408
409     def get_build(self, tree, host, compiler):
410         basename = self.build_fname(tree, host, compiler)
411         logf = "%s.log" % basename
412         if not os.path.exists(logf):
413             raise NoSuchBuildError(tree, host, compiler)
414         return CachingBuild(self, basename, tree, host, compiler)
415
416
417 class BuildResultStore(object):
418     """The build farm build result database."""
419
420     def __init__(self, path):
421         """Open the database.
422
423         :param path: Build result base directory
424         """
425         self.path = path
426
427     def get_build(self, tree, host, compiler, rev):
428         basename = self.build_fname(tree, host, compiler, rev)
429         logf = "%s.log" % basename
430         if not os.path.exists(logf):
431             raise NoSuchBuildError(tree, host, compiler, rev)
432         return Build(basename, tree, host, compiler, rev)
433
434     def build_fname(self, tree, host, compiler, rev):
435         """get the name of the build file"""
436         return os.path.join(self.path, "build.%s.%s.%s-%s" % (tree, host, compiler, rev))
437
438     def get_old_revs(self, tree, host, compiler):
439         """get a list of old builds and their status."""
440         ret = []
441         logfiles = [d for d in os.listdir(self.path) if d.startswith("build.%s.%s.%s-" % (tree, host, compiler)) and d.endswith(".log")]
442         for l in logfiles:
443             m = re.match(".*-([0-9A-Fa-f]+).log$", l)
444             if m:
445                 rev = m.group(1)
446                 stat = os.stat(os.path.join(self.path, l))
447                 # skip the current build
448                 if stat.st_nlink == 2:
449                     continue
450                 ret.append(self.get_build(tree, host, compiler, rev))
451
452         ret.sort(lambda a, b: cmp(a.age_mtime(), b.age_mtime()))
453
454         return ret
455
456     def upload_build(self, build):
457         (rev, rev_timestamp) = build.revision_details()
458
459         if not rev:
460             raise Exception("Unable to find revision in %r log" % build)
461
462         new_basename = self.build_fname(build.tree, build.host, build.compiler, rev)
463         try:
464             existing_build = self.get_build(build.tree, build.host, build.compiler, rev)
465         except NoSuchBuildError:
466             pass
467         else:
468             existing_build.remove_logs()
469         os.link(build.basename+".log", new_basename+".log")
470         if os.path.exists(build.basename+".err"):
471             os.link(build.basename+".err", new_basename+".err")
472
473     def get_previous_revision(self, tree, host, compiler, revision):
474         raise NoSuchBuildError(tree, host, compiler, revision)
475
476     def get_latest_revision(self, tree, host, compiler):
477         raise NoSuchBuildError(tree, host, compiler)
478
479
480 class CachingBuildResultStore(BuildResultStore):
481
482     def __init__(self, basedir, cachedir, readonly=False):
483         super(CachingBuildResultStore, self).__init__(basedir)
484
485         self.cachedir = cachedir
486         check_dir_exists("cache", self.cachedir)
487
488         self.readonly = readonly
489
490     def get_build(self, tree, host, compiler, rev):
491         basename = self.build_fname(tree, host, compiler, rev)
492         logf = "%s.log" % basename
493         if not os.path.exists(logf):
494             raise NoSuchBuildError(tree, host, compiler, rev)
495         return CachingBuild(self, basename, tree, host, compiler, rev)
496
497     def cache_fname(self, tree, host, compiler, rev):
498         return os.path.join(self.cachedir, "build.%s.%s.%s-%s" % (tree, host, compiler, rev))