#!/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] elif isenv.match(cmd[0]): # We did not insert env or sudo before the initial # variable assignment. Don't rely on the shell to # handle that (breaks for 'foo="x" "y"'), instead # use env. cmd.insert(0, 'env') 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--