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