Made the necessary adaptations for the dashboard launch and fixed some bugs (already live).

Will now recognize warnings as a status. Returns proper HTTP status codes for the most common errors now. Will be more strict when checking build status data (no newlines in bot names).

Prepared cron scripts.

Prepared dashboard for production use.

BUG=
TEST=

Review URL: https://webrtc-codereview.appspot.com/404003

git-svn-id: http://webrtc.googlecode.com/svn/trunk@1772 4adac7df-926f-26a2-2b94-8c16560cd09d
This commit is contained in:
phoglund@webrtc.org 2012-02-27 15:42:25 +00:00
parent 52b59d095e
commit 914ef27c63
15 changed files with 137 additions and 78 deletions

View File

@ -14,8 +14,7 @@ __author__ = 'phoglund@webrtc.org (Patrik Höglund)'
# This identifies our application using the information we got when we
# registered the application on Google appengine.
# TODO(phoglund): update to the right value when we have registered the app.
DASHBOARD_SERVER = 'localhost:8080'
DASHBOARD_SERVER = 'webrtc-dashboard.appspot.com'
DASHBOARD_SERVER_HTTP = 'http://' + DASHBOARD_SERVER
CONSUMER_KEY = DASHBOARD_SERVER
CONSUMER_SECRET_FILE = 'consumer.secret'
@ -30,9 +29,9 @@ ACCESS_TOKEN_URL = DASHBOARD_SERVER_HTTP + '/_ah/OAuthGetAccessToken'
BUILD_MASTER_SERVER = 'webrtc-cb-linux-master.cbf.corp.google.com:8010'
BUILD_MASTER_TRANSPOSED_GRID_URL = '/tgrid'
# The build-bot user which runs build bot jobs.
BUILD_BOT_USER = 'phoglund'
# Build bot constants.
BUILD_BOT_COVERAGE_WWW_DIRECTORY = '/var/www/'
# Dashboard data input URLs.
ADD_COVERAGE_DATA_URL = '/add_coverage_data'
ADD_BUILD_STATUS_DATA_URL = '/add_build_status_data'
ADD_COVERAGE_DATA_URL = DASHBOARD_SERVER_HTTP + '/add_coverage_data'
ADD_BUILD_STATUS_DATA_URL = DASHBOARD_SERVER_HTTP + '/add_build_status_data'

View File

@ -13,12 +13,13 @@
__author__ = 'phoglund@webrtc.org (Patrik Höglund)'
import datetime
import logging
from google.appengine.ext import db
import oauth_post_request_handler
VALID_STATUSES = ['OK', 'failed', 'building']
VALID_STATUSES = ['OK', 'failed', 'building', 'warnings']
class OrphanedBuildStatusesExistException(Exception):
@ -85,6 +86,8 @@ def _parse_name(revision_and_bot_name):
revision = parsed_name[0]
bot_name = parsed_name[1]
if '\n' in bot_name:
raise ValueError('Bot name %s can not contain newlines.' % bot_name)
return (int(revision), bot_name)
@ -142,7 +145,9 @@ class AddBuildStatusData(oauth_post_request_handler.OAuthPostRequestHandler):
(build_number, status) = _parse_status(build_number_and_status)
(revision, bot_name) = _parse_name(revision_and_bot_name)
except ValueError as error:
self._show_error_page('Invalid parameter in request: %s.' % error)
logger.warn('Invalid parameter in request: %s.' % error)
self.response.set_status(400)
return
if revision not in encountered_revisions:
# There's new data on this revision in this update, so clear all status
@ -163,3 +168,4 @@ class AddBuildStatusData(oauth_post_request_handler.OAuthPostRequestHandler):
request_datetime = datetime.datetime.fromtimestamp(request_posix_timestamp)
build_status_root.last_updated_at = request_datetime
build_status_root.put()

View File

@ -13,6 +13,7 @@
__author__ = 'phoglund@webrtc.org (Patrik Höglund)'
import datetime
import logging
from google.appengine.ext import db
@ -53,9 +54,9 @@ class AddCoverageData(oauth_post_request_handler.OAuthPostRequestHandler):
function_coverage_string = self.request.get('function_coverage')
function_coverage = _parse_percentage(function_coverage_string)
except ValueError as exception:
self._show_error_page('Invalid parameter in request. Details: %s' %
exception)
except ValueError as error:
logger.warn('Invalid parameter in request: %s.' % error)
self.response.set_status(400)
return
item = CoverageData(date=parsed_date,

View File

@ -1,14 +1,24 @@
application: dashboard
application: webrtc-dashboard
version: 1
runtime: python27
api_version: 1
threadsafe: false
handlers:
# Serve stylesheets statically.
- url: /stylesheets
static_dir: stylesheets
# This magic file is here to prove to the Google Account Domain Management
# that we own this domain. It needs to stay there so the domain management
# doesn't get suspicious.
- url: /google403c95edcde16425.html
static_files: static/google403c95edcde16425.html
upload: static/google403c95edcde16425.html
# Note: tests should be disabled in production.
# - url: /test.*
# script: gaeunit.py
# Redirect all other requests to our dynamic handlers.
- url: /.*
script: dashboard.app

View File

@ -32,13 +32,16 @@ class ShowDashboard(webapp2.RequestHandler):
build_status_loader = load_build_status.BuildStatusLoader()
build_status_data = build_status_loader.load_build_status_data()
last_updated_at = build_status_loader.load_last_modified_at()
if last_updated_at is None:
self._show_error_page("No data has yet been uploaded to the dashboard.")
return
last_updated_at = last_updated_at.strftime("%Y-%m-%d %H:%M")
lkgr = build_status_loader.compute_lkgr()
coverage_loader = load_coverage.CoverageDataLoader()
coverage_json_data = coverage_loader.load_coverage_json_data()
# Fill in the template with the data and respond:
page_template_filename = 'templates/dashboard_template.html'
self.response.write(template.render(page_template_filename, vars()))

View File

@ -15,8 +15,12 @@ __author__ = 'phoglund@webrtc.org (Patrik Höglund)'
from google.appengine.ext import db
def _status_not_ok(status):
return status not in ('OK', 'warnings')
def _all_ok(statuses):
return filter(lambda status: status != "OK", statuses) == []
return filter(_status_not_ok, statuses) == []
def _get_first_entry(iterable):
@ -32,7 +36,7 @@ class BuildStatusLoader:
def load_build_status_data(self):
"""Returns the latest conclusive build status for each bot.
The statuses OK or failed are considered to be conclusive.
The statuses OK, failed and warnings are considered to be conclusive.
The two most recent revisions are considered. The set of bots returned
will therefore be the bots that were reported the two most recent
@ -49,7 +53,7 @@ class BuildStatusLoader:
bots_to_latest_conclusive_entry = dict()
for entry in build_status_entries:
if entry.status == "building":
if entry.status == 'building':
# The 'building' status it not conclusive, so discard this entry and
# pick up the entry for this bot on the next revision instead. That
# entry is guaranteed to have a status != 'building' since a bot cannot

View File

@ -13,6 +13,7 @@
__author__ = 'phoglund@webrtc.org (Patrik Höglund)'
from google.appengine.api import oauth
import logging
import webapp2
@ -38,7 +39,8 @@ class OAuthPostRequestHandler(webapp2.RequestHandler):
try:
self._authenticate_user()
except UserNotAuthenticatedException as exception:
self._show_error_page('Failed to authenticate user: %s' % exception)
logging.warn('Failed to authenticate: %s.' % exception)
self.response.set_status(403)
return
# Do the actual work.
@ -46,7 +48,6 @@ class OAuthPostRequestHandler(webapp2.RequestHandler):
def _parse_and_store_data(self):
"""Reads data from POST request and responds accordingly."""
raise NotImplementedError('You must override this method!')
def _authenticate_user(self):
@ -54,6 +55,8 @@ class OAuthPostRequestHandler(webapp2.RequestHandler):
if oauth.is_current_user_admin():
# The user on whose behalf we are acting is indeed an administrator
# of this application, so we're good to go.
logging.info('Authenticated on behalf of user %s.' %
oauth.get_current_user())
return
else:
raise UserNotAuthenticatedException('We are acting on behalf of '
@ -62,7 +65,4 @@ class OAuthPostRequestHandler(webapp2.RequestHandler):
oauth.get_current_user())
except oauth.OAuthRequestError as exception:
raise UserNotAuthenticatedException('Invalid OAuth request: %s' %
exception)
def _show_error_page(self, error_message):
self.response.write('<html><body>%s</body></html>' % error_message)
exception.__class__.__name__)

View File

@ -0,0 +1 @@
google-site-verification: google403c95edcde16425.html

View File

@ -25,6 +25,11 @@
background-color: #fffc6c;
}
.status_warnings {
color: #000000;
background-color: #FFC343;
}
.last_known_good_revision {
font-size: 800%;
}

View File

@ -51,7 +51,7 @@
<h1>WebRTC Quality Dashboard</h1>
<h2>Current Build Status</h2>
<div>(as of {{ last_updated_at }})</div>
<div>(as of {{ last_updated_at }} UTC)</div>
<table>
<tr>
{% for entry in build_status_data %}

View File

@ -14,6 +14,7 @@ __author__ = 'phoglund@webrtc.org (Patrik Höglund)'
import httplib
import shelve
import urlparse
import oauth.oauth as oauth
import constants
@ -35,12 +36,9 @@ class DashboardConnection:
authenticated using OAuth. This class requires a consumer secret and a
access token.
The consumer secret is created manually on the machine running the script
(only authorized users should be able to log into the machine and see the
secret though). The access token is generated by the
request_oauth_permission.py script. Both these values are stored as files
on disk, in the scripts' working directory, according to the formats
prescribed in the read_required_files method.
The access token and consumer secrets are stored as files on disk in the
working directory of the scripts. Both files are created by the
request_oauth_permission script.
"""
def __init__(self, consumer_key):
@ -50,15 +48,15 @@ class DashboardConnection:
"""Reads required data for making OAuth requests.
Args:
consumer_secret_file: A plain text file with a single line containing
the consumer secret string.
consumer_secret_file: A shelve file with an entry consumer_secret
containing the consumer secret in string form.
access_token_file: A shelve file with an entry access_token
containing the access token in string form.
"""
self.access_token_ = self._read_access_token(access_token_file)
self.access_token_string_ = self._read_access_token(access_token_file)
self.consumer_secret_ = self._read_consumer_secret(consumer_secret_file)
def send_post_request(self, sub_url, parameters):
def send_post_request(self, url, parameters):
"""Sends an OAuth request for a protected resource in the dashboard.
Use this when you want to report new data to the dashboard. You must have
@ -72,8 +70,8 @@ class DashboardConnection:
information in the response.
Args:
sub_url: A relative url within the dashboard domain, for example
/add_coverage_data.
url: An absolute url within the dashboard domain, for example
http://webrtc-dashboard.appspot.com/add_coverage_data.
parameters: A dict which maps from POST parameter names to values.
Raises:
@ -81,30 +79,31 @@ class DashboardConnection:
with HTTP 200 to our request or if the response is non-empty.
"""
consumer = oauth.OAuthConsumer(self.consumer_key_, self.consumer_secret_)
create_oauth_request = oauth.OAuthRequest.from_consumer_and_token
oauth_request = create_oauth_request(consumer,
token=self.access_token_,
http_method='POST',
http_url=sub_url,
parameters=parameters)
access_token = oauth.OAuthToken.from_string(self.access_token_string_)
oauth_request = oauth.OAuthRequest.from_consumer_and_token(
consumer,
token=access_token,
http_method='POST',
http_url=url,
parameters=parameters)
signature_method_hmac_sha1 = oauth.OAuthSignatureMethod_HMAC_SHA1()
oauth_request.sign_request(signature_method_hmac_sha1, consumer,
self.access_token_)
access_token)
connection = httplib.HTTPConnection(constants.DASHBOARD_SERVER)
headers = {'Content-Type': 'application/x-www-form-urlencoded'}
connection.request('POST', sub_url, body=oauth_request.to_postdata(),
connection.request('POST', url, body=oauth_request.to_postdata(),
headers=headers)
response = connection.getresponse()
connection.close()
if response.status != 200:
message = ('Failed to report to %s%s: got response %d (%s)' %
(constants.DASHBOARD_SERVER, sub_url, response.status,
response.reason))
message = ('Failed to report to %s: got response %d (%s)' %
(url, response.status, response.reason))
raise FailedToReportToDashboard(message)
# The response content should be empty on success, so check that:
@ -115,29 +114,21 @@ class DashboardConnection:
raise FailedToReportToDashboard(message)
def _read_access_token(self, filename):
return self._read_shelve(filename, 'access_token')
def _read_consumer_secret(self, filename):
return self._read_shelve(filename, 'consumer_secret')
def _read_shelve(self, filename, key):
input_file = shelve.open(filename)
if not input_file.has_key('access_token'):
if not input_file.has_key(key):
raise FailedToReadRequiredInputFile('Missing correct %s file in current '
'directory. You may have to run '
'request_oauth_permission.py.' %
filename)
token = input_file['access_token']
result = input_file[key]
input_file.close()
return oauth.OAuthToken.from_string(token)
def _read_consumer_secret(self, filename):
try:
input_file = open(filename, 'r')
except IOError as error:
raise FailedToReadRequiredInputFile(error)
whole_file = input_file.read()
if whole_file.count('\n') > 1 or not whole_file.strip():
raise FailedToReadRequiredInputFile('Expected a single line with the '
'consumer secret in file %s.' %
filename)
return whole_file
return result

View File

@ -13,18 +13,21 @@
The script is intended to be run manually whenever we wish to change which
dashboard administrator we act on behalf of when running the
track_coverage.py script. For example, this will be useful if the current
dashboard administrator leaves the project.
dashboard administrator leaves the project. This script can also be used to
launch a new dashboard if that is desired.
This script should be run on the build bot which runs the track_coverage.py
script. This script will present a link during its execution, which the new
administrator should follow and then click approve on the web page that
appears. The new administrator should have admin rights on the coverage
dashboard, otherwise track_coverage.py will not work.
dashboard, otherwise the track_* scripts will not work.
If successful, this script will write the access token to a file access.token
in the current directory, which later can be read by track_coverage.py.
in the current directory, which later can be read by the track_* scripts.
The token is stored in string form (as reported by the web server) using the
shelve module.
shelve module. The consumer secret passed in as an argument to this script
will also similarly be stored in a file consumer.secret. The shelve keys
will be 'access_token' and 'consumer_secret', respectively.
"""
__author__ = 'phoglund@webrtc.org (Patrik Höglund)'
@ -106,11 +109,19 @@ def _write_access_token_to_file(access_token, filename):
print 'Wrote the access token to the file %s.' % filename
def _write_consumer_secret_to_file(consumer_secret, filename):
output = shelve.open(filename)
output['consumer_secret'] = consumer_secret
output.close()
print 'Wrote the consumer secret to the file %s.' % filename
def _main():
if len(sys.argv) != 2:
print ('Usage: %s <consumer secret>.\n\nThe consumer secret is an OAuth '
'concept and is obtained from the appengine running the dashboard.' %
sys.argv[0])
'concept and is obtained from the Google Accounts domain dashboard.'
% sys.argv[0])
return
consumer_secret = sys.argv[1]
@ -124,6 +135,8 @@ def _main():
access_token_string = _request_access_token(consumer, unauthorized_token)
_write_access_token_to_file(access_token_string, constants.ACCESS_TOKEN_FILE)
_write_consumer_secret_to_file(consumer_secret,
constants.CONSUMER_SECRET_FILE)
if __name__ == '__main__':
_main()

View File

@ -29,7 +29,8 @@ def _parse_builds(revision, html):
result = {}
for match in re.finditer('<td.*?>.*?<a href="builders/(.+?)/builds/(\d+)">'
'(OK|failed|building)</a>.*?</td>', html, re.DOTALL):
'(OK|failed|building|warnings)</a>.*?</td>',
html, re.DOTALL):
revision_and_bot_name = revision + "--" + match.group(1)
build_number_and_status = match.group(2) + "--" + match.group(3)
@ -54,8 +55,8 @@ def parse_tgrid_page(html):
html: The raw HTML from the tgrid page.
Returns: A dictionary with <svn revision>--<bot name> mapped to
<bot build number>--<status>, where status is either OK, failed or
building.
<bot build number>--<status>, where status is either OK, failed,
building or warnings.
"""
result = {}

View File

@ -34,6 +34,9 @@ SAMPLE_FILE = """
<table class="Grid" border="0" cellspacing="0">
<tr>
<td valign="bottom" class="sourcestamp">1570</td>
<td class="build warnings"><a href="builders/Chrome/builds/109">warnings</a>
<br />
make chrome</td>
<td class="build success">
<a href="builders/Android/builds/121">OK</a></td>
<td class="build success">
@ -61,6 +64,9 @@ SAMPLE_FILE = """
</tr>
<tr>
<td valign="bottom" class="sourcestamp">1571</td>
<td class="build warnings"><a href="builders/Chrome/builds/109">warnings</a>
<br />
make chrome</td>
<td class="build success">
<a href="builders/Android/builds/122">OK</a></td>
<td class="build success">
@ -118,6 +124,15 @@ voe_auto_test</td>
</tr>
"""
MINIMAL_WARNED = """
<tr>
<td valign="bottom" class="sourcestamp">1576</td>
<td class="build warnings">
<a href="builders/Chrome/builds/109">warnings</a><br />
make chrome</td>
</tr>
"""
class TGridParserTest(unittest.TestCase):
def test_parser_throws_exception_on_empty_html(self):
self.assertRaises(tgrid_parser.FailedToParseBuildStatus,
@ -150,16 +165,28 @@ class TGridParserTest(unittest.TestCase):
self.assertEqual('1576--Win32Debug', first_mapping[0])
self.assertEqual('434--building', first_mapping[1])
def test_parser_finds_warned_bot(self):
result = tgrid_parser.parse_tgrid_page(MINIMAL_WARNED)
self.assertEqual(1, len(result), 'There is only one bot in the sample.')
first_mapping = result.items()[0]
self.assertEqual('1576--Chrome', first_mapping[0])
self.assertEqual('109--warnings', first_mapping[1])
def test_parser_finds_all_bots_and_revisions(self):
result = tgrid_parser.parse_tgrid_page(SAMPLE_FILE)
# 2 * 12 = 24 bots in sample
self.assertEqual(24, len(result))
# 2 * 13 = 26 bots in sample
self.assertEqual(26, len(result))
# Make some samples
self.assertTrue(result.has_key('1570--ChromeOS'))
self.assertEquals('578--OK', result['1570--ChromeOS'])
self.assertTrue(result.has_key('1570--Chrome'))
self.assertEquals('109--warnings', result['1570--Chrome'])
self.assertTrue(result.has_key('1570--LinuxCLANG'))
self.assertEquals('259--OK', result['1570--LinuxCLANG'])

View File

@ -55,7 +55,7 @@ def _find_latest_32bit_debug_build(www_directory_contents, coverage_www_dir):
www_directory_contents.sort(reverse=True)
for entry in www_directory_contents:
match = re.match('Linux32bitDBG_\d+', entry)
match = re.match('Linux32DBG_\d+', entry)
if match is not None:
return entry
@ -97,9 +97,7 @@ def _main():
dashboard.read_required_files(constants.CONSUMER_SECRET_FILE,
constants.ACCESS_TOKEN_FILE)
coverage_www_dir = os.path.join('/home', constants.BUILD_BOT_USER, 'www')
www_dir_contents = os.listdir(coverage_www_dir)
www_dir_contents = os.listdir(BUILD_BOT_COVERAGE_WWW_DIRECTORY)
latest_build_directory = _find_latest_32bit_debug_build(www_dir_contents,
coverage_www_dir)