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