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