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