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