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