#!/usr/bin/env python3 # This file is part of Cockpit. # # Copyright (C) 2016 Red Hat, Inc. # # Cockpit is free software; you can redistribute it and/or modify it # under the terms of the GNU Lesser General Public License as published by # the Free Software Foundation; either version 2.1 of the License, or # (at your option) any later version. # # Cockpit is distributed in the hope that it will be useful, but # WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU # Lesser General Public License for more details. # # You should have received a copy of the GNU Lesser General Public License # along with Cockpit; If not, see . import argparse import os import socket import subprocess import sys import traceback sys.dont_write_bytecode = True from task import github from task import sink HOSTNAME = socket.gethostname().split(".")[0] BOTS = os.path.abspath(os.path.dirname(__file__)) BASE = os.path.normpath(os.path.join(BOTS, "..")) DEVNULL = open("/dev/null", "r+") def main(): parser = argparse.ArgumentParser(description='Run integration tests') parser.add_argument('--rebase', help="Rebase onto the specific branch before testing") parser.add_argument('-o', "--offline", action='store_true', help="Work offline, don''t fetch new data from origin for rebase") parser.add_argument('--publish', dest='publish', default=os.environ.get("TEST_PUBLISH", ""), action='store', help='Publish results centrally to a sink') parser.add_argument('-v', '--verbose', action='store_true', help='Verbose output') parser.add_argument('--pull-number', help="The number of the pull request to test") parser.add_argument('--html-logs', action='store_true', help="Use log.html to prettify the raw logs") parser.add_argument('context', help="The context or type of integration tests to run") opts = parser.parse_args() name = os.environ.get("TEST_NAME", "tests") revision = os.environ.get("TEST_REVISION") test_project = os.environ.get("TEST_PROJECT") # branch name when explicitly given in the test status # e.g. PR opened against master for test `rhel-8-0@cockpit-project/cockpit/rhel-8.0` # TEST_BRANCH would be `rhel-8.0` test_branch = os.environ.get("TEST_BRANCH") try: task = PullTask(name, revision, opts.context, opts.rebase, test_project=test_project, test_branch=test_branch, html_logs=opts.html_logs) ret = task.run(opts) except RuntimeError as ex: ret = str(ex) if ret: sys.stderr.write("tests-invoke: {0}\n".format(ret)) return 1 return 0 class PullTask(object): def __init__(self, name, revision, context, base, test_project, test_branch, html_logs): self.name = name self.revision = revision self.context = context self.base = base self.test_project = test_project self.test_branch = test_branch self.html_logs = html_logs self.sink = None self.github_status_data = None def detect_collisions(self, opts): api = github.GitHub() if opts.pull_number: pull = api.get("pulls/{0}".format(opts.pull_number)) if pull: if pull["head"]["sha"] != self.revision: return "Newer revision available on GitHub for this pull request" if pull["state"] != "open": return "Pull request isn't open" if not self.revision: return None statuses = api.get("commits/{0}/statuses".format(self.revision)) for status in statuses: if status.get("context") == self.context: if status.get("state") == "pending" and status.get("description") not in [github.NOT_TESTED, github.NOT_TESTED_DIRECT]: return "Status of context isn't pending or description is not in [{0}, {1}]".format(github.NOT_TESTED, github.NOT_TESTED_DIRECT) else: # only check the newest status of the supplied context return None def start_publishing(self, host): api = github.GitHub() # build a unique file name for this test run id_context = "-".join([self.test_project, self.test_branch or "", self.context]) identifier = "-".join([ self.name.replace("/", "-"), self.revision[0:8], id_context.replace("/", "-") ]) description = "{0} [{1}]".format(github.TESTING, HOSTNAME) # build a globally unique test context for GitHub statuses github_context = self.context if self.test_branch: github_context += "@" + self.test_project + "/" + self.test_branch elif self.test_project != api.repo : # disambiguate test name for foreign project tests github_context += "@" + self.test_project self.github_status_data = { "state": "pending", "context": github_context, "description": description, "target_url": ":link" } status = { "github": { "token": api.token, "requests": [ # Set status to pending { "method": "POST", "resource": api.qualify("commits/" + self.revision + "/statuses"), "data": self.github_status_data } ], "watches": [{ "resource": api.qualify("commits/" + self.revision + "/status?per_page=100"), "result": { "statuses": [ { "context": github_context, "description": description, "target_url": ":link" } ] } }] }, "revision": self.revision, "onaborted": { "github": { "token": api.token, "requests": [ # Set status to error { "method": "POST", "resource": api.qualify("statuses/" + self.revision), "data": { "state": "error", "context": self.context, "description": "Aborted without status", "target_url": ":link" } } ] }, } } if self.html_logs: # explicit request for html logs status["link"] = "log.html" status["extras"] = [ "https://raw.githubusercontent.com/cockpit-project/cockpit/master/bots/task/log.html" ] elif self.test_project != "cockpit-project/cockpit": # third-party project, link directly to text log status["link"] = "log" else: # Testing cockpit itself, use HTML log from current # revision. Use log.html from code under test, but only # if we are on master. Other branches don't have a bots/ # in their repo. status["link"] = "log.html" status["extras"] = [ "https://raw.githubusercontent.com/cockpit-project/cockpit/{0}/bots/task/log.html".format(self.revision if not self.base else "master") ] # Include information about which base we're testing against if self.base: subprocess.check_call([ "git", "fetch", "origin", self.base ]) commit = subprocess.check_output([ "git", "rev-parse", "origin/" + self.base ], universal_newlines=True).strip() status["base"] = commit if not self.base: status['irc'] = { } # Only send to IRC when master # For other scripts to use os.environ["TEST_DESCRIPTION"] = description self.sink = sink.Sink(host, identifier, status) def rebase(self): remote_base = "origin" + "/" + self.base # Rebase this branch onto the base, but only if it's not already an ancestor try: if subprocess.call([ "git", "merge-base", "--is-ancestor", remote_base, "HEAD" ]) != 0: sha = subprocess.check_output([ "git", "rev-parse", remote_base ], universal_newlines=True).strip() sys.stderr.write("Rebasing onto {0} ({1}) ...\n".format(remote_base, sha)) subprocess.check_call([ "git", "reset", "HEAD" ]) subprocess.check_call([ "git", "rebase", remote_base ]) except subprocess.CalledProcessError: subprocess.call([ "git", "rebase", "--abort" ]) traceback.print_exc() return "Rebase failed" return None def stop_publishing(self, ret): sink = self.sink def mark_failed(): if "github" in sink.status: self.github_status_data["state"] = "failure" if "irc" in sink.status: # Never send success messages to IRC sink.status["irc"]["channel"] = "#cockpit" def mark_passed(): if "github" in sink.status: self.github_status_data["state"] = "success" if isinstance(ret, str): message = ret mark_failed() ret = 0 elif ret == 0: message = "Tests passed" mark_passed() else: message = "Tests failed with code {0}".format(ret) mark_failed() ret = 0 # A failure, but not for this script sink.status["message"] = message if "github" in sink.status: self.github_status_data["description"] = message try: del sink.status["extras"] except KeyError: pass sink.flush() return ret def run(self, opts): # Collision detection ret = self.detect_collisions(opts) if ret: sys.stderr.write('Collision detected: {0}\n'.format(ret)) return None if opts.publish: self.start_publishing(opts.publish) os.environ["TEST_ATTACHMENTS"] = self.sink.attachments head = subprocess.check_output([ "git", "rev-parse", "HEAD" ], universal_newlines=True).strip() if not self.revision: self.revision = head # Retrieve information about our base branch and master (for bots/) if self.base and not opts.offline: subprocess.check_call([ "git", "fetch", "origin", self.base, "master" ]) # Clean out the test directory subprocess.check_call([ "git", "clean", "-d", "--force", "--quiet", "-x", "--", "test/" ]) os.environ["TEST_NAME"] = self.name os.environ["TEST_REVISION"] = self.revision # Split OS and potential scenario (image, _, scenario) = self.context.partition("/") if scenario: os.environ["TEST_SCENARIO"] = scenario os.environ["TEST_OS"] = image msg = "Testing {0} for {1} with {2} on {3}...\n".format(self.revision, self.name, self.context, HOSTNAME) sys.stderr.write(msg) ret = None if self.base: ret = self.rebase() # Flush our own output before running command sys.stdout.flush() sys.stderr.flush() # Actually run the tests if not ret: entry_point = os.path.join(BOTS, "..", ".cockpit-ci", "run") alt_entry_point = os.path.join(BOTS, "..", "test", "run") if not os.path.exists(entry_point) and os.path.exists(alt_entry_point): entry_point = alt_entry_point cmd = [ "timeout", "120m", entry_point ] if opts.verbose: cmd.append("--verbose") ret = subprocess.call(cmd) # All done if self.sink: ret = self.stop_publishing(ret) return ret if __name__ == '__main__': sys.exit(main())