#!/usr/bin/python
"""
The general idea is that tests to run are defined as a list of
actions. Each action has a unique name and can depend on other
actions to have run successfully before.
Most work is executed in directories defined and owned by these
actions. The framework only manages one directory which represents
the result of each action:
- an overview file which lists the result of each action
- for each action a directory with stderr/out and additional files
that the action can put there
"""
import os, sys, popen2, traceback, re, time, smtplib, optparse, stat, shutil, StringIO, MimeWriter
import shlex
import subprocess
import fnmatch
import copy
try:
import gzip
havegzip = True
except:
havegzip = False
def cd(path):
"""Enter directories, creating them if necessary."""
if not os.access(path, os.F_OK):
os.makedirs(path)
os.chdir(path)
def abspath(path):
"""Absolute path after expanding vars and user."""
return os.path.abspath(os.path.expanduser(os.path.expandvars(path)))
def findInPaths(name, dirs):
"""find existing item in one of the directories, return None if
no directories give, absolute path to existing item or (as fallbac)
last dir + name"""
fullname = None
for dir in dirs:
fullname = os.path.join(abspath(dir), name)
if os.access(fullname, os.F_OK):
break
return fullname
def del_dir(path):
if not os.access(path, os.F_OK):
return
for file in os.listdir(path):
file_or_dir = os.path.join(path,file)
# ensure directory is writable
os.chmod(path, os.stat(path)[stat.ST_MODE] | stat.S_IRWXU)
if os.path.isdir(file_or_dir) and not os.path.islink(file_or_dir):
del_dir(file_or_dir) #it's a directory recursive call to function again
else:
os.remove(file_or_dir) #it's a file, delete it
os.rmdir(path)
def copyLog(filename, dirname, htaccess, lineFilter=None):
"""Make a gzipped copy (if possible) with the original time stamps and find the most severe problem in it.
That line is then added as description in a .htaccess AddDescription.
For directories just copy the whole directory tree.
"""
info = os.stat(filename)
outname = os.path.join(dirname, os.path.basename(filename))
if os.path.isdir(filename):
# copy whole directory, without any further processing at the moment
shutil.copytree(filename, outname, symlinks=True)
return
# .out files are typically small nowadays, so don't compress
if False:
outname = outname + ".gz"
out = gzip.open(outname, "wb")
else:
out = file(outname, "w")
error = None
for line in file(filename, "r").readlines():
if not error and line.find("ERROR") >= 0:
error = line
if lineFilter:
line = lineFilter(line)
out.write(line)
out.close()
os.utime(outname, (info[stat.ST_ATIME], info[stat.ST_MTIME]))
if error:
error = error.strip().replace("\"", "'").replace("<", "<").replace(">",">")
htaccess.write("AddDescription \"%s\" %s\n" %
(error,
os.path.basename(filename)))
return error
def TryKill(pid, signal):
try:
os.kill(pid, signal)
except OSError, ex:
# might have quit in the meantime, deal with the race
# condition
if ex.errno != 3:
raise ex
def ShutdownSubprocess(popen, timeout):
start = time.time()
if popen.poll() == None:
TryKill(popen.pid, signal.SIGTERM)
while popen.poll() == None and start + timeout >= time.time():
time.sleep(0.01)
if popen.poll() == None:
TryKill(popen.pid, signal.SIGKILL)
while popen.poll() == None and start + timeout + 1 >= time.time():
time.sleep(0.01)
return False
return True
class Action:
"""Base class for all actions to be performed."""
DONE = "0 DONE"
WARNINGS = "1 WARNINGS"
FAILED = "2 FAILED"
TODO = "3 TODO"
SKIPPED = "4 SKIPPED"
COMPLETED = (DONE, WARNINGS)
def __init__(self, name):
self.name = name
self.status = self.TODO
self.summary = ""
self.dependencies = []
self.isserver = False;
def execute(self):
"""Runs action. Throws an exeception if anything fails.
Will be called by tryexecution() with stderr/stdout redirected into a file
and the current directory set to an empty temporary directory.
"""
raise Exception("not implemented")
def nop(self):
pass
def tryexecution(self, step, logs):
"""wrapper around execute which handles exceptions, directories and stdout"""
print "*** running action %s" % self.name
if logs:
fd = -1
oldstdout = os.dup(1)
oldstderr = os.dup(2)
oldout = sys.stdout
olderr = sys.stderr
cwd = os.getcwd()
try:
subdirname = "%d-%s" % (step, self.name)
del_dir(subdirname)
sys.stderr.flush()
sys.stdout.flush()
cd(subdirname)
if logs:
fd = os.open("output.txt", os.O_WRONLY|os.O_CREAT|os.O_TRUNC)
os.dup2(fd, 1)
os.dup2(fd, 2)
sys.stdout = os.fdopen(fd, "w")
sys.stderr = sys.stdout
print "=== starting %s ===" % (self.name)
self.execute()
self.status = Action.DONE
self.summary = "okay"
except Exception, inst:
traceback.print_exc()
self.status = Action.FAILED
self.summary = str(inst)
print "\n=== %s: %s ===" % (self.name, self.status)
sys.stdout.flush()
os.chdir(cwd)
if logs:
if fd >= 0:
sys.stdout.close()
os.dup2(oldstdout, 1)
os.dup2(oldstderr, 2)
sys.stderr = olderr
sys.stdout = oldout
os.close(oldstdout)
os.close(oldstderr)
return self.status
class Context:
"""Provides services required by actions and handles running them."""
def __init__(self, tmpdir, resultdir, uri, workdir, mailtitle, sender, recipients, mailhost, enabled, skip, nologs, setupcmd, make, sanitychecks, lastresultdir, datadir):
# preserve normal stdout because stdout/stderr will be redirected
self.out = os.fdopen(os.dup(1), "w")
self.todo = []
self.actions = {}
self.tmpdir = abspath(tmpdir)
self.resultdir = abspath(resultdir)
self.uri = uri
self.workdir = abspath(workdir)
self.summary = []
self.mailtitle = mailtitle
self.sender = sender
self.recipients = recipients
self.mailhost = mailhost
self.enabled = enabled
self.skip = skip
self.nologs = nologs
self.setupcmd = setupcmd
self.make = make
self.sanitychecks = sanitychecks
self.lastresultdir = lastresultdir
self.datadir = datadir
def findTestFile(self, name):
"""find item in SyncEvolution test directory, first using the
generated source of the current test, then the bootstrapping code"""
return findInPaths(name, (os.path.join(sync.basedir, "test"), self.datadir))
def runCommand(self, cmdstr, dumpCommands=False):
"""Log and run the given command, throwing an exception if it fails."""
cmd = shlex.split(cmdstr)
if "valgrindcheck.sh" in cmdstr:
cmd.insert(0, "VALGRIND_LOG=%s" % os.getenv("VALGRIND_LOG", ""))
cmd.insert(0, "VALGRIND_ARGS=%s" % os.getenv("VALGRIND_ARGS", ""))
cmd.insert(0, "VALGRIND_LEAK_CHECK_ONLY_FIRST=%s" % os.getenv("VALGRIND_LEAK_CHECK_ONLY_FIRST", ""))
cmd.insert(0, "VALGRIND_LEAK_CHECK_SKIP=%s" % os.getenv("VALGRIND_LEAK_CHECK_SKIP", ""))
# move "sudo" or "env" command invocation in front of
# all the leading env variable assignments: necessary
# because sudo ignores them otherwise
command = 0
isenv = re.compile(r'[a-zA-Z0-9_]*=.*')
while isenv.match(cmd[command]):
command = command + 1
if cmd[command] in ("env", "sudo"):
cmd.insert(0, cmd[command])
del cmd[command + 1]
cmdstr = " ".join(map(lambda x: (' ' in x or x == '') and ("'" in x and '"%s"' or "'%s'") % x or x, cmd))
if dumpCommands:
cmdstr = "set -x; " + cmdstr
print "*** ( cd %s; export %s; %s )" % (os.getcwd(),
" ".join(map(lambda x: "'%s=%s'" % (x, os.getenv(x, "")), [ "LD_LIBRARY_PATH" ])),
cmdstr)
sys.stdout.flush()
result = os.system(cmdstr)
if result != 0:
raise Exception("%s: failed (return code %d)" % (cmd, result>>8))
def add(self, action):
"""Add an action for later execution. Order is important, fifo..."""
self.todo.append(action)
self.actions[action.name] = action
def required(self, actionname):
"""Returns true if the action is required by one which is enabled."""
if actionname in self.enabled:
return True
for action in self.todo:
if actionname in action.dependencies and self.required(action.name):
return True
return False
def execute(self):
cd(self.resultdir)
s = open("output.txt", "w+")
status = Action.DONE
step = 0
run_servers=[];
while len(self.todo) > 0:
try:
step = step + 1
# get action
action = self.todo.pop(0)
# check whether it actually needs to be executed
if self.enabled and \
not action.name in self.enabled and \
not self.required(action.name):
# disabled
action.status = Action.SKIPPED
self.summary.append("%s skipped: disabled in configuration" % (action.name))
elif action.name in self.skip:
# assume that it was done earlier
action.status = Action.SKIPPED
self.summary.append("%s assumed to be done: requested by configuration" % (action.name))
else:
# check dependencies
for depend in action.dependencies:
if not self.actions[depend].status in Action.COMPLETED:
action.status = Action.SKIPPED
self.summary.append("%s skipped: required %s has not been executed" % (action.name, depend))
break
if action.status == Action.SKIPPED:
continue
# execute it
if action.isserver:
run_servers.append(action.name);
action.tryexecution(step, not self.nologs)
if action.status > status:
status = action.status
if action.status == Action.FAILED:
self.summary.append("%s: %s" % (action.name, action.summary))
elif action.status == Action.WARNINGS:
self.summary.append("%s done, but check the warnings" % action.name)
else:
self.summary.append("%s successful" % action.name)
except Exception, inst:
traceback.print_exc()
self.summary.append("%s failed: %s" % (action.name, inst))
# append all parameters to summary
self.summary.append("")
self.summary.extend(sys.argv)
# update summary
s.write("%s\n" % ("\n".join(self.summary)))
s.close()
# copy information about sources
for source in self.actions.keys():
action = self.actions[source]
basedir = getattr(action, 'basedir', None)
if basedir and os.path.isdir(basedir):
for file in os.listdir(os.path.join(basedir, "..")):
if fnmatch.fnmatch(file, source + '[.-]*'):
shutil.copyfile(os.path.join(basedir, "..", file),
os.path.join(self.resultdir, file))
# run testresult checker
#calculate the src dir where client-test can be located
srcdir = os.path.join(compile.builddir, "src")
backenddir = os.path.join(compile.installdir, "usr/lib/syncevolution/backends")
# resultchecker doesn't need valgrind, remove it
shell = re.sub(r'\S*valgrind\S*', '', options.shell)
prefix = re.sub(r'\S*valgrind\S*', '', options.testprefix)
uri = self.uri or ("file:///" + self.resultdir)
resultchecker = self.findTestFile("resultchecker.py")
compare = self.findTestFile("compare.xsl")
generateHTML = self.findTestFile("generate-html.xsl")
commands = []
# produce nightly.xml from plain text log files
commands.append(resultchecker + " " +self.resultdir+" "+"'"+",".join(run_servers)+"'"+" "+uri +" "+srcdir + " '" + shell + " " + testprefix +" '"+" '" +backenddir +"'")
previousxml = os.path.join(self.lastresultdir, "nightly.xml")
if os.path.exists(previousxml):
# compare current nightly.xml against previous file
commands.append("xsltproc -o " + self.resultdir + "/cmp_result.xml --stringparam cmp_file " + previousxml + " " + compare + " " + self.resultdir + "/nightly.xml")
# produce HTML with URLs relative to current directory of the nightly.html
commands.append("xsltproc -o " + self.resultdir + "/nightly.html --stringparam url . --stringparam cmp_result_file " + self.resultdir + "/cmp_result.xml " + generateHTML + " "+ self.resultdir+"/nightly.xml")
self.runCommand(" && ".join(commands))
# report result by email
if self.recipients:
server = smtplib.SMTP(self.mailhost)
msg=''
try:
msg = open(self.resultdir + "/nightly.html").read()
except IOError:
msg = '''
Error: No HTML report generated!
\n'''
# insert absolute URL into hrefs so that links can be opened directly in
# the mail reader
msg = re.sub(r'href="([a-zA-Z0-9./])',
'href="' + uri + r'/\1',
msg)
body = StringIO.StringIO()
writer = MimeWriter.MimeWriter (body)
writer.addheader("From", self.sender)
for recipient in self.recipients:
writer.addheader("To", recipient)
writer.addheader("Subject", self.mailtitle + ": " + os.path.basename(self.resultdir))
writer.addheader("MIME-Version", "1.0")
writer.flushheaders()
writer.startbody("text/html;charset=ISO-8859-1").write(msg)
failed = server.sendmail(self.sender, self.recipients, body.getvalue())
if failed:
print "could not send to: %s" % (failed)
sys.exit(1)
else:
print "\n".join(self.summary), "\n"
if status in Action.COMPLETED:
sys.exit(0)
else:
sys.exit(1)
# must be set before instantiating some of the following classes
context = None
class CVSCheckout(Action):
"""Does a CVS checkout (if directory does not exist yet) or an update (if it does)."""
def __init__(self, name, workdir, runner, cvsroot, module, revision):
"""workdir defines the directory to do the checkout in,
cvsroot the server, module the path to the files,
revision the tag to checkout"""
Action.__init__(self,name)
self.workdir = workdir
self.runner = runner
self.cvsroot = cvsroot
self.module = module
self.revision = revision
self.basedir = os.path.join(abspath(workdir), module)
def execute(self):
cd(self.workdir)
if os.access(self.module, os.F_OK):
os.chdir(self.module)
context.runCommand("cvs update -d -r %s" % (self.revision))
elif self.revision == "HEAD":
context.runCommand("cvs -d %s checkout %s" % (self.cvsroot, self.module))
os.chdir(self.module)
else:
context.runCommand("cvs -d %s checkout -r %s %s" % (self.cvsroot, self.revision, self.module))
os.chdir(self.module)
if os.access("autogen.sh", os.F_OK):
context.runCommand("%s ./autogen.sh" % (self.runner))
class SVNCheckout(Action):
"""Does a Subversion checkout (if directory does not exist yet) or a switch (if it does)."""
def __init__(self, name, workdir, runner, url, module):
"""workdir defines the directory to do the checkout in,
URL the server and path inside repository,
module the path to the files in the checked out copy"""
Action.__init__(self,name)
self.workdir = workdir
self.runner = runner
self.url = url
self.module = module
self.basedir = os.path.join(abspath(workdir), module)
def execute(self):
cd(self.workdir)
if os.access(self.module, os.F_OK):
cmd = "switch"
else:
cmd = "checkout"
context.runCommand("svn %s %s %s" % (cmd, self.url, self.module))
os.chdir(self.module)
if os.access("autogen.sh", os.F_OK):
context.runCommand("%s ./autogen.sh" % (self.runner))
class GitCheckoutBase:
"""Just sets some common properties for all Git checkout classes: workdir, basedir"""
def __init__(self, name, workdir):
self.workdir = workdir
self.basedir = os.path.join(abspath(workdir), name)
class GitCheckout(GitCheckoutBase, Action):
"""Does a git clone (if directory does not exist yet) or a fetch+checkout (if it does)."""
def __init__(self, name, workdir, runner, url, revision):
"""workdir defines the directory to do the checkout in with 'name' as name of the sub directory,
URL the server and repository,
revision the desired branch or tag"""
Action.__init__(self, name)
GitCheckoutBase.__init__(self, name)
self.runner = runner
self.url = url
self.revision = revision
def execute(self):
if os.access(self.basedir, os.F_OK):
cmd = "cd %s && git fetch" % (self.basedir)
else:
cmd = "git clone %s %s && chmod -R g+w %s && cd %s && git config core.sharedRepository group " % (self.url, self.basedir, self.basedir, self.basedir)
context.runCommand(cmd)
context.runCommand("set -x; cd %(dir)s && git show-ref &&"
"((git tag -l | grep -w -q %(rev)s) && git checkout %(rev)s ||"
"((git branch -l | grep -w -q %(rev)s) && git checkout %(rev)s || git checkout -b %(rev)s origin/%(rev)s) && git merge origin/%(rev)s)" %
{"dir": self.basedir,
"rev": self.revision})
os.chdir(self.basedir)
if os.access("autogen.sh", os.F_OK):
context.runCommand("%s ./autogen.sh" % (self.runner))
class GitCopy(GitCheckoutBase, Action):
"""Copy existing git repository and update it to the requested
branch, with local changes stashed before updating and restored
again afterwards. Automatically merges all branches with /
as prefix, skips those which do not apply cleanly."""
def __init__(self, name, workdir, runner, sourcedir, revision):
"""workdir defines the directory to create/update the repo in with 'name' as name of the sub directory,
sourcedir a directory which must contain such a repo already,
revision the desired branch or tag"""
Action.__init__(self, name)
GitCheckoutBase.__init__(self, name, workdir)
self.runner = runner
self.sourcedir = sourcedir
self.revision = revision
self.patchlog = os.path.join(abspath(workdir), name + "-source.log")
self.__getitem__ = lambda x: getattr(self, x)
def execute(self):
if not os.access(self.basedir, os.F_OK):
context.runCommand("(mkdir -p %s && cp -a -l %s/%s %s) || ( rm -rf %s && false )" %
(self.workdir, self.sourcedir, self.name, self.workdir, self.basedir))
os.chdir(self.basedir)
cmd = " && ".join([
'rm -f %(patchlog)s',
'echo "save local changes with stash under a fixed name -nightly"',
'rev=$(git stash create)',
'git branch -f %(revision)s-nightly ${rev:-HEAD}',
'echo "check out branch as "nightly" and integrate all proposed patches (= /... branches)"',
# switch to detached head, to allow removal of branches
'git checkout -q $( git show-ref --head --hash | head -1 )',
'if git branch | grep -q -w "^..%(revision)s$"; then git branch -D %(revision)s; fi',
'if git branch | grep -q -w "^..nightly$"; then git branch -D nightly; fi',
# fetch
'echo "remove stale merge branches and fetch anew"',
'git branch -r -D $( git branch -r | grep -e "/for-%(revision)s/" ) ',
'git branch -D $( git branch | grep -e "^ for-%(revision)s/" ) ',
'git fetch',
'git fetch --tags',
# pick tag or remote branch
'if git tag | grep -q -w %(revision)s; then base=%(revision)s; git checkout -f -b nightly %(revision)s; ' \
'else base=origin/%(revision)s; git checkout -f -b nightly origin/%(revision)s; fi',
# integrate remote branches first, followed by local ones;
# the hope is that local branches apply cleanly on top of the remote ones
'for patch in $( (git branch -r --no-merged origin/%(revision)s; git branch --no-merged origin/%(revision)s) | sed -e "s/^..//" | grep -e "^for-%(revision)s/" -e "/for-%(revision)s/" ); do ' \
'if git merge $patch; then echo >>%(patchlog)s $patch: okay; ' \
'else echo >>%(patchlog)s $patch: failed to apply; git reset --hard; fi; done',
'echo "restore -nightly and create permanent branch -nightly-before--