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