d1b8f1713df1f48082d9bcdf5f321accb23af003
[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):
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 class NoSuchBuildError(Exception):
203     """The build with the specified name does not exist."""
204
205     def __init__(self, tree, host, compiler, rev=None):
206         self.tree = tree
207         self.host = host
208         self.compiler = compiler
209         self.rev = rev
210
211
212 class Build(object):
213     """A single build of a tree on a particular host using a particular compiler.
214     """
215
216     def __init__(self, basename, tree, host, compiler, rev=None):
217         self.basename = basename
218         self.tree = tree
219         self.host = host
220         self.compiler = compiler
221         self.revision = rev
222
223     def __cmp__(self, other):
224         return cmp(
225             (self.upload_time, self.revision, self.host, self.tree, self.compiler),
226             (other.upload_time, other.revision, other.host, other.tree, other.compiler))
227
228     def __eq__(self, other):
229         return (isinstance(other, Build) and
230                 self.log_checksum() == other.log_checksum())
231
232     def __repr__(self):
233         if self.revision is not None:
234             return "<%s: revision %s of %s on %s using %s>" % (self.__class__.__name__, self.revision, self.tree, self.host, self.compiler)
235         else:
236             return "<%s: %s on %s using %s>" % (self.__class__.__name__, self.tree, self.host, self.compiler)
237
238     def remove_logs(self):
239         os.unlink(self.basename + ".log")
240         if os.path.exists(self.basename+".err"):
241             os.unlink(self.basename+".err")
242
243     def remove(self):
244         self.remove_logs()
245
246     @property
247     def upload_time(self):
248         """get timestamp of build"""
249         st = os.stat("%s.log" % self.basename)
250         return st.st_mtime
251
252     @property
253     def age(self):
254         """get the age of build"""
255         return time.time() - self.upload_time
256
257     def read_log(self):
258         """read full log file"""
259         try:
260             return open(self.basename+".log", "r")
261         except IOError:
262             raise LogFileMissing()
263
264     def read_err(self):
265         """read full err file"""
266         try:
267             return open(self.basename+".err", 'r')
268         except IOError:
269             # No such file
270             return StringIO()
271
272     def log_checksum(self):
273         f = self.read_log()
274         try:
275             return hashlib.sha1(f.read()).hexdigest()
276         finally:
277             f.close()
278
279     def summary(self):
280         (revid, timestamp) = self.revision_details()
281         status = self.status()
282         return BuildSummary(self.host, self.tree, self.compiler, revid, status)
283
284     def revision_details(self):
285         """get the revision of build
286
287         :return: Tuple with revision id and timestamp (if available)
288         """
289         revid = None
290         timestamp = None
291         f = self.read_log()
292         try:
293             for l in f:
294                 if l.startswith("BUILD COMMIT REVISION: "):
295                     revid = l.split(":", 1)[1].strip()
296                 elif l.startswith("BUILD COMMIT TIME"):
297                     timestamp = l.split(":", 1)[1].strip()
298         finally:
299             f.close()
300
301         if revid is None:
302             raise MissingRevisionInfo(self)
303
304         return (revid, timestamp)
305
306     def status(self):
307         """get status of build
308
309         :return: tuple with build status
310         """
311         log = self.read_log()
312         try:
313             err = self.read_err()
314             try:
315                 return build_status_from_logs(log, err)
316             finally:
317                 err.close()
318         finally:
319             log.close()
320
321     def err_count(self):
322         """get status of build"""
323         file = self.read_err()
324         return len(file.readlines())
325
326
327 class UploadBuildResultStore(object):
328
329     def __init__(self, path):
330         """Open the database.
331
332         :param path: Build result base directory
333         """
334         self.path = path
335
336     def get_new_builds(self):
337         for name in os.listdir(self.path):
338             try:
339                 (build, tree, host, compiler, extension) = name.split(".")
340             except ValueError:
341                 continue
342             if build != "build" or extension != "log":
343                 continue
344             yield self.get_build(tree, host, compiler)
345
346     def build_fname(self, tree, host, compiler):
347         return os.path.join(self.path, "build.%s.%s.%s" % (tree, host, compiler))
348
349     def has_host(self, host):
350         for name in os.listdir(self.path):
351             try:
352                 if name.split(".")[2] == host:
353                     return True
354             except IndexError:
355                 pass
356         return False
357
358     def get_build(self, tree, host, compiler):
359         basename = self.build_fname(tree, host, compiler)
360         logf = "%s.log" % basename
361         if not os.path.exists(logf):
362             raise NoSuchBuildError(tree, host, compiler)
363         return Build(basename, tree, host, compiler)
364
365
366 class BuildResultStore(object):
367     """The build farm build result database."""
368
369     def __init__(self, path):
370         """Open the database.
371
372         :param path: Build result base directory
373         """
374         self.path = path
375
376     def __contains__(self, build):
377         try:
378             if build.revision:
379                 rev = build.revision
380             else:
381                 rev, timestamp = build.revision_details()
382             self.get_build(build.tree, build.host, build.compiler, rev)
383         except NoSuchBuildError:
384             return False
385         else:
386             return True
387
388     def get_build(self, tree, host, compiler, rev, checksum=None):
389         basename = self.build_fname(tree, host, compiler, rev)
390         logf = "%s.log" % basename
391         if not os.path.exists(logf):
392             raise NoSuchBuildError(tree, host, compiler, rev)
393         return Build(basename, tree, host, compiler, rev)
394
395     def build_fname(self, tree, host, compiler, rev):
396         """get the name of the build file"""
397         return os.path.join(self.path, "build.%s.%s.%s-%s" % (tree, host, compiler, rev))
398
399     def get_all_builds(self):
400         for l in os.listdir(self.path):
401             m = re.match("^build\.([0-9A-Za-z]+)\.([0-9A-Za-z]+)\.([0-9A-Za-z]+)-([0-9A-Fa-f]+).log$", l)
402             if not m:
403                 continue
404             tree = m.group(1)
405             host = m.group(2)
406             compiler = m.group(3)
407             rev = m.group(4)
408             stat = os.stat(os.path.join(self.path, l))
409             # skip the current build
410             if stat.st_nlink == 2:
411                 continue
412             yield self.get_build(tree, host, compiler, rev)
413
414     def get_old_builds(self, tree, host, compiler):
415         """get a list of old builds and their status."""
416         ret = []
417         for build in self.get_all_builds():
418             if build.tree == tree and build.host == host and build.compiler == compiler:
419                 ret.append(build)
420         ret.sort(lambda a, b: cmp(a.upload_time, b.upload_time))
421         return ret
422
423     def upload_build(self, build):
424         (rev, rev_timestamp) = build.revision_details()
425
426         new_basename = self.build_fname(build.tree, build.host, build.compiler, rev)
427         try:
428             existing_build = self.get_build(build.tree, build.host, build.compiler, rev)
429         except NoSuchBuildError:
430             if os.path.exists(new_basename+".log"):
431                 os.remove(new_basename+".log")
432             if os.path.exists(new_basename+".err"):
433                 os.remove(new_basename+".err")
434         else:
435             existing_build.remove_logs()
436         os.link(build.basename+".log", new_basename+".log")
437         if os.path.exists(build.basename+".err"):
438             os.link(build.basename+".err", new_basename+".err")
439         return Build(new_basename, build.tree, build.host, build.compiler, rev)
440
441     def get_previous_revision(self, tree, host, compiler, revision):
442         raise NoSuchBuildError(tree, host, compiler, revision)
443
444     def get_latest_revision(self, tree, host, compiler):
445         raise NoSuchBuildError(tree, host, compiler)