add function to get the lcov cache status on a build store
[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
25 from cStringIO import StringIO
26 import hashlib
27 import os
28 import re
29 import time
30 import util
31
32
33 class BuildSummary(object):
34
35     def __init__(self, host, tree, compiler, rev, status):
36         self.host = host
37         self.tree = tree
38         self.compiler = compiler
39         self.rev = rev
40         self.status = status
41
42
43 class BuildStatus(object):
44
45     def __init__(self, stages=None, other_failures=None):
46         if stages is not None:
47             self.stages = stages
48         else:
49             self.stages = []
50         if other_failures is not None:
51             self.other_failures = other_failures
52         else:
53             self.other_failures = set()
54
55     def broken_host(self):
56         if "disk full" in self.other_failures:
57             return True
58         return False
59
60     def _status_tuple(self):
61         return [v for (k, v) in self.stages]
62
63     def regressed_since(self, other):
64         """Check if this build has regressed since another build."""
65         if "disk full" in self.other_failures:
66             return False
67         return cmp(self._status_tuple(), other._status_tuple())
68
69     def __cmp__(self, other):
70         other_extra = other.other_failures - self.other_failures
71         self_extra = self.other_failures - other.other_failures
72         # Give more importance to other failures
73         if other_extra:
74             return 1
75         if self_extra:
76             return -1
77
78         la = len(self.stages)
79         lb = len(other.stages)
80         if la > lb:
81             return 1
82         elif lb > la:
83             return -1
84         else:
85             return cmp(other.stages, self.stages)
86
87     def __str__(self):
88         return repr((self.stages, self.other_failures))
89
90
91 def check_dir_exists(kind, path):
92     if not os.path.isdir(path):
93         raise Exception("%s directory %s does not exist" % (kind, path))
94
95
96 def build_status_from_logs(log, err):
97     """get status of build"""
98     test_failures = 0
99     test_successes = 0
100     test_seen = 0
101     ret = BuildStatus()
102
103     stages = []
104
105     for l in log:
106         m = re.match("^([A-Z_]+) STATUS:(\s*\d+)$", l)
107         if m:
108             stages.append((m.group(1), int(m.group(2).strip())))
109             if m.group(1) == "TEST":
110                 test_seen = 1
111             continue
112         m = re.match("^ACTION (PASSED|FAILED):\s+test$", l)
113         if m and not test_seen:
114             if m.group(1) == "PASSED":
115                 stages.append(("TEST", 0))
116             else:
117                 stages.append(("TEST", 1))
118             continue
119
120         if l.startswith("No space left on device"):
121             ret.other_failures.add("disk full")
122             continue
123         if l.startswith("maximum runtime exceeded"):
124             ret.other_failures.add("timeout")
125             continue
126         m = re.match("^(PANIC|INTERNAL ERROR):.*$", l)
127         if m:
128             ret.other_failures.add("panic")
129             continue
130         if l.startswith("testsuite-failure: ") or l.startswith("testsuite-error: "):
131             test_failures += 1
132             continue
133         if l.startswith("testsuite-success: "):
134             test_successes += 1
135             continue
136
137     # Scan err file for specific errors
138     for l in err:
139         if "No space left on device" in l:
140             ret.other_failures.add("disk full")
141
142     def map_stage(name, result):
143         if name != "TEST":
144             return (name, result)
145         # TEST is special
146         if test_successes + test_failures == 0:
147             # No granular test output
148             return ("TEST", result)
149         if result == 1 and test_failures == 0:
150             ret.other_failures.add("inconsistent test result")
151             return ("TEST", -1)
152         return ("TEST", test_failures)
153
154     ret.stages = [map_stage(name, result) for (name, result) in stages]
155     return ret
156
157
158 class NoSuchBuildError(Exception):
159     """The build with the specified name does not exist."""
160
161     def __init__(self, tree, host, compiler, rev=None):
162         self.tree = tree
163         self.host = host
164         self.compiler = compiler
165         self.rev = rev
166
167
168 class Build(object):
169     """A single build of a tree on a particular host using a particular compiler.
170     """
171
172     def __init__(self, store, tree, host, compiler, rev=None):
173         self._store = store
174         self.tree = tree
175         self.host = host
176         self.compiler = compiler
177         self.rev = rev
178         if rev is None:
179             self.basename = self._store.build_fname(self.tree, self.host, self.compiler)
180         else:
181             self.basename = self._store.build_fname(self.tree, self.host, self.compiler, self.rev)
182
183     ###################
184     # the mtime age is used to determine if builds are still happening
185     # on a host.
186     # the ctime age is used to determine when the last real build happened
187
188     def age_mtime(self):
189         """get the age of build from mtime"""
190         st = os.stat("%s.log" % self.basename)
191         return time.time() - st.st_mtime
192
193     def age_ctime(self):
194         """get the age of build from ctime"""
195         st = os.stat("%s.log" % self.basename)
196         return time.time() - st.st_ctime
197
198     def read_log(self):
199         """read full log file"""
200         return open(self.basename+".log", "r")
201
202     def read_err(self):
203         """read full err file"""
204         try:
205             return open(self.basename+".err", 'r')
206         except IOError:
207             # No such file
208             return StringIO()
209
210     def log_checksum(self):
211         f = self.read_log()
212         try:
213             return hashlib.sha1(f.read()).hexdigest()
214         finally:
215             f.close()
216
217     def summary(self):
218         (revid, commit_revid, timestamp) = self.revision_details()
219         if commit_revid:
220             revid = commit_revid
221         status = self.status()
222         return BuildSummary(self.host, self.tree, self.compiler, revid, status)
223
224     def revision_details(self):
225         """get the revision of build
226
227         :return: Tuple with revision id and timestamp (if available)
228         """
229
230         revid = None
231         commit_revid = None
232         timestamp = None
233         f = self.read_log()
234         try:
235             for l in f:
236                 if l.startswith("BUILD COMMIT REVISION: "):
237                     commit_revid = l.split(":", 1)[1].strip()
238                 elif l.startswith("BUILD REVISION: "):
239                     revid = l.split(":", 1)[1].strip()
240                 elif l.startswith("BUILD COMMIT TIME"):
241                     timestamp = l.split(":", 1)[1].strip()
242         finally:
243             f.close()
244
245         return (revid, commit_revid, timestamp)
246
247     def status(self):
248         """get status of build
249
250         :return: tuple with build status
251         """
252         log = self.read_log()
253         try:
254             err = self.read_err()
255             try:
256                 return build_status_from_logs(log, err)
257             finally:
258                 err.close()
259         finally:
260             log.close()
261
262     def err_count(self):
263         """get status of build"""
264         file = self.read_err()
265         return len(file.readlines())
266
267
268 class CachingBuild(Build):
269     """Build subclass that caches some of the results that are expensive
270     to calculate."""
271
272     def revision_details(self):
273         cachef = self._store.cache_fname(self.tree, self.host, self.compiler, self.rev)
274         st1 = os.stat("%s.log" % self.basename)
275
276         try:
277             st2 = os.stat("%s.revision" % cachef)
278         except OSError:
279             # File does not exist
280             st2 = None
281
282         # the ctime/mtime asymmetry is needed so we don't get fooled by
283         # the mtime update from rsync
284         if st2 and st1.st_ctime <= st2.st_mtime:
285             (revid, commit_revid, timestamp) = util.FileLoad("%s.revision" % cachef).split(":", 2)
286             if timestamp == "":
287                 timestamp = None
288             if revid == "":
289                 revid = None
290             if commit_revid == "":
291                 commit_revid = None
292             return (revid, commit_revid, timestamp)
293         (revid, commit_revid, timestamp) = super(CachingBuild, self).revision_details()
294         if not self._store.readonly:
295             util.FileSave("%s.revision" % cachef, "%s:%s:%s" % (revid, commit_revid or "", timestamp or ""))
296         return (revid, commit_revid, timestamp)
297
298     def err_count(self):
299         cachef = self._store.cache_fname(self.tree, self.host, self.compiler, self.rev)
300         st1 = os.stat("%s.err" % self.basename)
301
302         try:
303             st2 = os.stat("%s.errcount" % cachef)
304         except OSError:
305             # File does not exist
306             st2 = None
307
308         if st2 and st1.st_ctime <= st2.st_mtime:
309             return util.FileLoad("%s.errcount" % cachef)
310
311         ret = super(CachingBuild, self).err_count()
312
313         if not self._store.readonly:
314             util.FileSave("%s.errcount" % cachef, str(ret))
315
316         return ret
317
318     def status(self):
319         cachefile = self._store.cache_fname(self.tree, self.host, self.compiler, self.rev)+".status"
320
321         st1 = os.stat("%s.log" % self.basename)
322
323         try:
324             st2 = os.stat(cachefile)
325         except OSError:
326             # No such file
327             st2 = None
328
329         if st2 and st1.st_ctime <= st2.st_mtime:
330             return BuildStatus(*eval(util.FileLoad(cachefile)))
331
332         ret = super(CachingBuild, self).status()
333
334         if not self._store.readonly:
335             util.FileSave(cachefile, str(ret))
336
337         return ret
338
339
340 class UploadBuildResultStore(object):
341
342     def __init__(self, path):
343         """Open the database.
344
345         :param path: Build result base directory
346         """
347         self.path = path
348
349     def build_fname(self, tree, host, compiler):
350         return os.path.join(self.path, "build.%s.%s.%s" % (tree, host, compiler))
351
352     def has_host(self, host):
353         for name in os.listdir(self.path):
354             try:
355                 if name.split(".")[2] == host:
356                     return True
357             except IndexError:
358                 pass
359         return False
360
361     def get_build(self, tree, host, compiler):
362         logf = self.build_fname(tree, host, compiler) + ".log"
363         if not os.path.exists(logf):
364             raise NoSuchBuildError(tree, host, compiler)
365         return Build(self, tree, host, compiler)
366
367
368 class CachingUploadBuildResultStore(UploadBuildResultStore):
369
370     def __init__(self, basedir, cachedir, readonly=False):
371         """Open the database.
372
373         :param readonly: Whether to avoid saving cache files
374         """
375         super(CachingUploadBuildResultStore, self).__init__(basedir)
376         self.cachedir = cachedir
377         self.readonly = readonly
378
379     def cache_fname(self, tree, host, compiler):
380         return os.path.join(self.cachedir, "build.%s.%s.%s" % (tree, host, compiler))
381
382     def get_build(self, tree, host, compiler):
383         logf = self.build_fname(tree, host, compiler) + ".log"
384         if not os.path.exists(logf):
385             raise NoSuchBuildError(tree, host, compiler)
386         return CachingBuild(self, tree, host, compiler)
387
388
389 class BuildResultStore(object):
390     """The build farm build result database."""
391
392     def __init__(self, path):
393         """Open the database.
394
395         :param path: Build result base directory
396         """
397         self.path = path
398
399     def get_lcov_cached_status(self, host, tree):
400         return None
401
402     def get_build(self, tree, host, compiler, rev):
403         logf = self.build_fname(tree, host, compiler, rev) + ".log"
404         if not os.path.exists(logf):
405             raise NoSuchBuildError(tree, host, compiler, rev)
406         return Build(self, tree, host, compiler, rev)
407
408     def build_fname(self, tree, host, compiler, rev):
409         """get the name of the build file"""
410         return os.path.join(self.path, "build.%s.%s.%s-%s" % (tree, host, compiler, rev))
411
412     def get_old_revs(self, tree, host, compiler):
413         """get a list of old builds and their status."""
414         ret = []
415         logfiles = [d for d in os.listdir(self.path) if d.startswith("build.%s.%s.%s-" % (tree, host, compiler)) and d.endswith(".log")]
416         for l in logfiles:
417             m = re.match(".*-([0-9A-Fa-f]+).log$", l)
418             if m:
419                 rev = m.group(1)
420                 stat = os.stat(os.path.join(self.path, l))
421                 # skip the current build
422                 if stat.st_nlink == 2:
423                     continue
424                 build = self.get_build(tree, host, compiler, rev)
425                 r = {
426                     "STATUS": build.status(),
427                     "REVISION": rev,
428                     "TIMESTAMP": build.age_ctime(),
429                     }
430                 ret.append(r)
431
432         ret.sort(lambda a, b: cmp(a["TIMESTAMP"], b["TIMESTAMP"]))
433
434         return ret
435
436     def upload_build(self, build):
437         (rev, commit_rev, rev_timestamp) = build.revision_details()
438
439         if commit_rev is not None:
440             rev = commit_rev
441
442         new_basename = self.build_fname(build.tree, build.host, build.compiler, rev)
443         os.rename(build.basename+".log", new_basename+".log")
444         if os.path.exists(build.basename+".err"):
445             os.rename(build.basename+".err", new_basename+".err")
446
447         # FIXME:
448         # $st = $dbh->prepare("INSERT INTO build (tree, revision, commit_revision, host, compiler, checksum, age, status) VALUES (?, ?, ?, ?, ?, ?, ?, ?)")
449         # $st->execute($tree, $rev, $commit, $host, $compiler, $checksum, $stat->ctime, $status_html)
450
451 """
452     def get_previous_revision(self, tree, host, compiler, revision):
453         # Look up the database to find the previous status
454         $st = $dbh->prepare("SELECT status, revision, commit_revision FROM build WHERE tree = ? AND host = ? AND compiler = ? AND revision != ? AND commit_revision != ? ORDER BY id DESC LIMIT 1")
455         $st->execute( $tree, $host, $compiler, $rev, $commit)
456
457         while ( my @row = $st->fetchrow_array ) {
458             $old_status_html = @row[0]
459             $old_rev = @row[1]
460             $old_commit = @row[2]
461         """
462
463
464 class CachingBuildResultStore(BuildResultStore):
465
466     def __init__(self, basedir, cachedir, readonly=False):
467         super(CachingBuildResultStore, self).__init__(basedir)
468
469         self.cachedir = cachedir
470         check_dir_exists("cache", self.cachedir)
471
472         self.readonly = readonly
473
474     def get_build(self, tree, host, compiler, rev):
475         logf = self.build_fname(tree, host, compiler, rev) + ".log"
476         if not os.path.exists(logf):
477             raise NoSuchBuildError(tree, host, compiler, rev)
478         return CachingBuild(self, tree, host, compiler, rev)
479
480     def cache_fname(self, tree, host, compiler, rev):
481         return os.path.join(self.cachedir, "build.%s.%s.%s-%s" % (tree, host, compiler, rev))
482
483     def get_lcov_cached_status(self, host, tree):
484         return os.path.join(self.cachedir, "lcov.%s.%s.status" % (host, tree))
485
486