318 lines
12 KiB
Python
Executable File
318 lines
12 KiB
Python
Executable File
#!/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 <http://www.gnu.org/licenses/>.
|
|
|
|
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())
|