Add test and test_result classes.
[build-farm.git] / buildfarm / build.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
31
32 class Test(object):
33
34     def __init__(self, name):
35         self.name = name
36
37
38
39 class TestResult(object):
40
41     def __init__(self, build, test, result):
42         self.build = build
43         self.test = test
44         self.result = result
45
46
47 class BuildSummary(object):
48
49     def __init__(self, host, tree, compiler, revision, status):
50         self.host = host
51         self.tree = tree
52         self.compiler = compiler
53         self.revision = revision
54         self.status = status
55
56
57 BuildStageResult = collections.namedtuple("BuildStageResult", "name result")
58
59
60 class MissingRevisionInfo(Exception):
61     """Revision info could not be found in the build log."""
62
63     def __init__(self, build=None):
64         self.build = build
65
66
67 class LogFileMissing(Exception):
68     """Log file missing."""
69
70
71 class BuildStatus(object):
72
73     def __init__(self, stages=None, other_failures=None):
74         if stages is not None:
75             self.stages = [BuildStageResult(n, r) for (n, r) in stages]
76         else:
77             self.stages = []
78         if other_failures is not None:
79             self.other_failures = other_failures
80         else:
81             self.other_failures = set()
82
83     @property
84     def failed(self):
85         if self.other_failures:
86             return True
87         return not all([x.result == 0 for x in self.stages])
88
89     def __serialize__(self):
90         return repr(self)
91
92     @classmethod
93     def __deserialize__(cls, text):
94         return eval(text)
95
96     def __str__(self):
97         if self.other_failures:
98             return ",".join(self.other_failures)
99         return "/".join([str(x.result) for x in self.stages])
100
101     def broken_host(self):
102         if "disk full" in self.other_failures:
103             return True
104         return False
105
106     def regressed_since(self, older):
107         """Check if this build has regressed since another build."""
108         if "disk full" in self.other_failures:
109             return False
110         if ("timeout" in self.other_failures and
111             "timeout" in older.other_failures):
112             # When the timeout happens exactly can differ slightly, so it's
113             # okay if the numbers are a bit different..
114             return False
115         if ("panic" in self.other_failures and
116             not "panic" in older.other_failures):
117             return True
118         if len(self.stages) < len(older.stages):
119             # Less stages completed
120             return True
121         for ((old_name, old_result), (new_name, new_result)) in zip(
122             older.stages, self.stages):
123             assert old_name == new_name
124             if new_result > old_result:
125                 return True
126         return False
127
128     def __cmp__(self, other):
129         other_extra = other.other_failures - self.other_failures
130         self_extra = self.other_failures - other.other_failures
131         # Give more importance to other failures
132         if other_extra:
133             return 1
134         if self_extra:
135             return -1
136
137         la = len(self.stages)
138         lb = len(other.stages)
139         if la > lb:
140             return 1
141         elif lb > la:
142             return -1
143         else:
144             return cmp(other.stages, self.stages)
145
146     def __repr__(self):
147         return "%s(%r, %r)" % (self.__class__.__name__, self.stages, self.other_failures)
148
149
150 def check_dir_exists(kind, path):
151     if not os.path.isdir(path):
152         raise Exception("%s directory %s does not exist" % (kind, path))
153
154
155 def build_status_from_logs(log, err):
156     """get status of build"""
157     # FIXME: Perhaps also extract revision here?
158
159     test_failures = 0
160     test_successes = 0
161     test_seen = 0
162     ret = BuildStatus()
163
164     stages = []
165     re_status = re.compile("^([A-Z_]+) STATUS:(\s*\d+)$")
166     re_action = re.compile("^ACTION (PASSED|FAILED):\s+test$")
167
168     for l in log:
169         if l.startswith("No space left on device"):
170             ret.other_failures.add("disk full")
171             continue
172         if "maximum runtime exceeded" in l: # Ugh.
173             ret.other_failures.add("timeout")
174             continue
175         if l.startswith("PANIC:") or l.startswith("INTERNAL ERROR:"):
176             ret.other_failures.add("panic")
177             continue
178         if l.startswith("testsuite-failure: ") or l.startswith("testsuite-error: "):
179             test_failures += 1
180             continue
181         if l.startswith("testsuite-success: "):
182             test_successes += 1
183             continue
184         m = re_status.match(l)
185         if m:
186             stages.append(BuildStageResult(m.group(1), int(m.group(2).strip())))
187             if m.group(1) == "TEST":
188                 test_seen = 1
189             continue
190         m = re_action.match(l)
191         if m and not test_seen:
192             if m.group(1) == "PASSED":
193                 stages.append(BuildStageResult("TEST", 0))
194             else:
195                 stages.append(BuildStageResult("TEST", 1))
196             continue
197
198     # Scan err file for specific errors
199     for l in err:
200         if "No space left on device" in l:
201             ret.other_failures.add("disk full")
202
203     def map_stage(sr):
204         if sr.name != "TEST":
205             return sr
206         # TEST is special
207         if test_successes + test_failures == 0:
208             # No granular test output
209             return BuildStageResult("TEST", sr.result)
210         if sr.result == 1 and test_failures == 0:
211             ret.other_failures.add("inconsistent test result")
212             return BuildStageResult("TEST", -1)
213         return BuildStageResult("TEST", test_failures)
214
215     ret.stages = map(map_stage, stages)
216     return ret
217
218
219 def revision_from_log(log):
220     revid = None
221     for l in log:
222         if l.startswith("BUILD COMMIT REVISION: "):
223             revid = l.split(":", 1)[1].strip()
224     if revid is None:
225         raise MissingRevisionInfo()
226     return revid
227
228
229 class NoSuchBuildError(Exception):
230     """The build with the specified name does not exist."""
231
232     def __init__(self, tree, host, compiler, rev=None):
233         self.tree = tree
234         self.host = host
235         self.compiler = compiler
236         self.rev = rev
237
238
239 class Build(object):
240     """A single build of a tree on a particular host using a particular compiler.
241     """
242
243     def __init__(self, basename, tree, host, compiler, rev=None):
244         self.basename = basename
245         self.tree = tree
246         self.host = host
247         self.compiler = compiler
248         self.revision = rev
249
250     def __cmp__(self, other):
251         return cmp(
252             (self.upload_time, self.revision, self.host, self.tree, self.compiler),
253             (other.upload_time, other.revision, other.host, other.tree, other.compiler))
254
255     def __eq__(self, other):
256         return (isinstance(other, Build) and
257                 self.log_checksum() == other.log_checksum())
258
259     def __repr__(self):
260         if self.revision is not None:
261             return "<%s: revision %s of %s on %s using %s>" % (self.__class__.__name__, self.revision, self.tree, self.host, self.compiler)
262         else:
263             return "<%s: %s on %s using %s>" % (self.__class__.__name__, self.tree, self.host, self.compiler)
264
265     def remove_logs(self):
266         # In general, basename.log should *always* exist.
267         if os.path.exists(self.basename+".log"):
268             os.unlink(self.basename + ".log")
269         if os.path.exists(self.basename+".err"):
270             os.unlink(self.basename+".err")
271
272     def remove(self):
273         self.remove_logs()
274
275     @property
276     def upload_time(self):
277         """get timestamp of build"""
278         st = os.stat("%s.log" % self.basename)
279         return st.st_mtime
280
281     @property
282     def age(self):
283         """get the age of build"""
284         return time.time() - self.upload_time
285
286     def read_log(self):
287         """read full log file"""
288         try:
289             return open(self.basename+".log", "r")
290         except IOError:
291             raise LogFileMissing()
292
293     def read_err(self):
294         """read full err file"""
295         try:
296             return open(self.basename+".err", 'r')
297         except IOError:
298             # No such file
299             return StringIO()
300
301     def log_checksum(self):
302         f = self.read_log()
303         try:
304             return hashlib.sha1(f.read()).hexdigest()
305         finally:
306             f.close()
307
308     def summary(self):
309         revid = self.revision_details()
310         status = self.status()
311         return BuildSummary(self.host, self.tree, self.compiler, revid, status)
312
313     def revision_details(self):
314         """get the revision of build
315
316         :return: revision id
317         """
318         f = self.read_log()
319         try:
320             return revision_from_log(f)
321         finally:
322             f.close()
323
324     def status(self):
325         """get status of build
326
327         :return: tuple with build status
328         """
329         log = self.read_log()
330         try:
331             err = self.read_err()
332             try:
333                 return build_status_from_logs(log, err)
334             finally:
335                 err.close()
336         finally:
337             log.close()
338
339     def err_count(self):
340         """get status of build"""
341         file = self.read_err()
342         return len(file.readlines())
343
344
345 class UploadBuildResultStore(object):
346
347     def __init__(self, path):
348         """Open the database.
349
350         :param path: Build result base directory
351         """
352         self.path = path
353
354     def get_all_builds(self):
355         for name in os.listdir(self.path):
356             try:
357                 (build, tree, host, compiler, extension) = name.split(".")
358             except ValueError:
359                 continue
360             if build != "build" or extension != "log":
361                 continue
362             yield self.get_build(tree, host, compiler)
363
364     def build_fname(self, tree, host, compiler):
365         return os.path.join(self.path, "build.%s.%s.%s" % (tree, host, compiler))
366
367     def has_host(self, host):
368         for name in os.listdir(self.path):
369             try:
370                 if name.split(".")[2] == host:
371                     return True
372             except IndexError:
373                 pass
374         return False
375
376     def get_build(self, tree, host, compiler):
377         basename = self.build_fname(tree, host, compiler)
378         logf = "%s.log" % basename
379         if not os.path.exists(logf):
380             raise NoSuchBuildError(tree, host, compiler)
381         return Build(basename, tree, host, compiler)
382
383
384 class BuildResultStore(object):
385     """The build farm build result database."""
386
387     def __init__(self, path):
388         """Open the database.
389
390         :param path: Build result base directory
391         """
392         self.path = path
393
394     def __contains__(self, build):
395         try:
396             if build.revision:
397                 rev = build.revision
398             else:
399                 rev = build.revision_details()
400             self.get_build(build.tree, build.host, build.compiler, rev)
401         except NoSuchBuildError:
402             return False
403         else:
404             return True
405
406     def get_build(self, tree, host, compiler, rev, checksum=None):
407         basename = self.build_fname(tree, host, compiler, rev)
408         logf = "%s.log" % basename
409         if not os.path.exists(logf):
410             raise NoSuchBuildError(tree, host, compiler, rev)
411         return Build(basename, tree, host, compiler, rev)
412
413     def build_fname(self, tree, host, compiler, rev):
414         """get the name of the build file"""
415         return os.path.join(self.path, "build.%s.%s.%s-%s" % (tree, host, compiler, rev))
416
417     def get_all_builds(self):
418         for l in os.listdir(self.path):
419             m = re.match("^build\.([0-9A-Za-z]+)\.([0-9A-Za-z]+)\.([0-9A-Za-z]+)-([0-9A-Fa-f]+).log$", l)
420             if not m:
421                 continue
422             tree = m.group(1)
423             host = m.group(2)
424             compiler = m.group(3)
425             rev = m.group(4)
426             stat = os.stat(os.path.join(self.path, l))
427             # skip the current build
428             if stat.st_nlink == 2:
429                 continue
430             yield self.get_build(tree, host, compiler, rev)
431
432     def get_old_builds(self, tree, host, compiler):
433         """get a list of old builds and their status."""
434         ret = []
435         for build in self.get_all_builds():
436             if build.tree == tree and build.host == host and build.compiler == compiler:
437                 ret.append(build)
438         ret.sort(lambda a, b: cmp(a.upload_time, b.upload_time))
439         return ret
440
441     def upload_build(self, build):
442         rev = build.revision_details()
443
444         new_basename = self.build_fname(build.tree, build.host, build.compiler, rev)
445         try:
446             existing_build = self.get_build(build.tree, build.host, build.compiler, rev)
447         except NoSuchBuildError:
448             if os.path.exists(new_basename+".log"):
449                 os.remove(new_basename+".log")
450             if os.path.exists(new_basename+".err"):
451                 os.remove(new_basename+".err")
452         else:
453             existing_build.remove_logs()
454         os.link(build.basename+".log", new_basename+".log")
455         if os.path.exists(build.basename+".err"):
456             os.link(build.basename+".err", new_basename+".err")
457         return Build(new_basename, build.tree, build.host, build.compiler, rev)
458
459     def get_previous_revision(self, tree, host, compiler, revision):
460         raise NoSuchBuildError(tree, host, compiler, revision)
461
462     def get_latest_revision(self, tree, host, compiler):
463         raise NoSuchBuildError(tree, host, compiler)