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