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