| # Copyright 2015 The Chromium Authors. All rights reserved. |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| |
| """Defines common functionality used for interacting with Rietveld.""" |
| |
| import json |
| import logging |
| import mimetypes |
| import urllib |
| |
| from google.appengine.ext import ndb |
| |
| from dashboard import utils |
| |
| _DESCRIPTION = """This patch was automatically uploaded by the Chrome Perf |
| Dashboard (https://chromeperf.appspot.com). It is being used to run a perf |
| bisect try job. It should not be submitted.""" |
| |
| |
| class ResponseObject(object): |
| """Class for Response Object. |
| |
| This class holds attributes similar to response object returned by |
| google.appengine.api.urlfetch. This is used to convert response object |
| returned by httplib2.Http.request. |
| """ |
| |
| def __init__(self, status_code, content): |
| self.status_code = int(status_code) |
| self.content = content |
| |
| |
| class RietveldConfig(ndb.Model): |
| """Configuration info for a Rietveld service account. |
| |
| The data is stored only in the App Engine datastore (and the cloud console) |
| and not the code because it contains sensitive information like private keys. |
| """ |
| # TODO(qyearsley): Remove RietveldConfig and store the server URL in |
| # datastore. |
| client_email = ndb.TextProperty() |
| service_account_key = ndb.TextProperty() |
| |
| # The protocol and domain of the Rietveld host. Should not contain path. |
| server_url = ndb.TextProperty() |
| |
| # The protocol and domain of the Internal Rietveld host which is used |
| # to create issues for internal only tests. |
| internal_server_url = ndb.TextProperty() |
| |
| |
| def GetDefaultRietveldConfig(): |
| """Returns the default rietveld config entity from the datastore.""" |
| return ndb.Key(RietveldConfig, 'default_rietveld_config').get() |
| |
| |
| class RietveldService(object): |
| """Implements a Python API to Rietveld via HTTP. |
| |
| Authentication is handled via an OAuth2 access token minted from an RSA key |
| associated with a service account (which can be created via the Google API |
| console). For this to work, the Rietveld instance to talk to must be |
| configured to allow the service account client ID as OAuth2 audience (see |
| Rietveld source). Both the RSA key and the server URL are provided via static |
| application configuration. |
| """ |
| |
| def __init__(self, internal_only=False): |
| self.internal_only = internal_only |
| self._config = None |
| self._http = None |
| |
| def Config(self): |
| if not self._config: |
| self._config = GetDefaultRietveldConfig() |
| return self._config |
| |
| def MakeRequest(self, path, *args, **kwargs): |
| """Makes a request to the Rietveld server.""" |
| if self.internal_only: |
| server_url = self.Config().internal_server_url |
| else: |
| server_url = self.Config().server_url |
| url = '%s/%s' % (server_url, path) |
| response, content = utils.ServiceAccountHttp().request(url, *args, **kwargs) |
| return ResponseObject(response.get('status'), content) |
| |
| def _XsrfToken(self): |
| """Requests a XSRF token from Rietveld.""" |
| return self.MakeRequest( |
| 'xsrf_token', headers={'X-Requesting-XSRF-Token': 1}).content |
| |
| def _EncodeMultipartFormData(self, fields, files): |
| """Encode form fields for multipart/form-data. |
| |
| Args: |
| fields: A sequence of (name, value) elements for regular form fields. |
| files: A sequence of (name, filename, value) elements for data to be |
| uploaded as files. |
| Returns: |
| (content_type, body) ready for httplib.HTTP instance. |
| |
| Source: |
| http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/146306 |
| """ |
| boundary = '-M-A-G-I-C---B-O-U-N-D-A-R-Y-' |
| crlf = '\r\n' |
| lines = [] |
| for (key, value) in fields: |
| lines.append('--' + boundary) |
| lines.append('Content-Disposition: form-data; name="%s"' % key) |
| lines.append('') |
| if isinstance(value, unicode): |
| value = value.encode('utf-8') |
| lines.append(value) |
| for (key, filename, value) in files: |
| lines.append('--' + boundary) |
| lines.append('Content-Disposition: form-data; name="%s"; filename="%s"' % |
| (key, filename)) |
| content_type = (mimetypes.guess_type(filename)[0] or |
| 'application/octet-stream') |
| lines.append('Content-Type: %s' % content_type) |
| lines.append('') |
| if isinstance(value, unicode): |
| value = value.encode('utf-8') |
| lines.append(value) |
| lines.append('--' + boundary + '--') |
| lines.append('') |
| body = crlf.join(lines) |
| content_type = 'multipart/form-data; boundary=%s' % boundary |
| return content_type, body |
| |
| def UploadPatch(self, subject, patch, base_checksum, base_hashes, |
| base_content, config_path): |
| """Uploads the given patch file contents to Rietveld. |
| |
| The process of creating an issue and uploading the patch requires several |
| HTTP requests to Rietveld. |
| |
| Rietveld API documentation: https://code.google.com/p/rietveld/wiki/APIs |
| For specific implementation in Rietveld codebase, see http://goo.gl/BW205J. |
| |
| Args: |
| subject: Title of the job, as it will appear in rietveld. |
| patch: The patch, which is a specially-formatted string. |
| base_checksum: Base md5 checksum to send. |
| base_hashes: "Base hashes" string to send. |
| base_content: Base config file contents. |
| config_path: Path to the config file. |
| |
| Returns: |
| A (issue ID, patchset ID) pair. These are strings that contain numerical |
| IDs. If the patch upload was unsuccessful, then (None, None) is returned. |
| """ |
| base = 'https://chromium.googlesource.com/chromium/src.git@master' |
| repo_guid = 'c14d891d44f0afff64e56ed7c9702df1d807b1ee' |
| form_fields = [ |
| ('subject', subject), |
| ('description', _DESCRIPTION), |
| ('base', base), |
| ('xsrf_token', self._XsrfToken()), |
| ('repo_guid', repo_guid), |
| ('content_upload', '1'), |
| ('base_hashes', base_hashes), |
| ] |
| uploaded_diff_file = [('data', 'data.diff', patch)] |
| ctype, body = self._EncodeMultipartFormData( |
| form_fields, uploaded_diff_file) |
| response = self.MakeRequest( |
| 'upload', method='POST', body=body, headers={'content-type': ctype}) |
| if response.status_code != 200: |
| logging.error('Error %s uploading to /upload', response.status_code) |
| logging.error(response.content) |
| return (None, None) |
| |
| # There should always be 3 lines in the request, but sometimes Rietveld |
| # returns 2 lines. Log the content so we can debug further. |
| logging.info('Response from Rietveld /upload:\n%s', response.content) |
| if not response.content.startswith('Issue created.'): |
| logging.error('Unexpected response: %s', response.content) |
| return (None, None) |
| lines = response.content.splitlines() |
| if len(lines) < 2: |
| logging.error('Unexpected response %s', response.content) |
| return (None, None) |
| |
| msg = lines[0] |
| issue_id = msg[msg.rfind('/') + 1:] |
| patchset_id = lines[1].strip() |
| patches = [x.split(' ', 1) for x in lines[2:]] |
| request_path = '%d/upload_content/%d/%d' % ( |
| int(issue_id), int(patchset_id), int(patches[0][0])) |
| form_fields = [ |
| ('filename', config_path), |
| ('status', 'M'), |
| ('checksum', base_checksum), |
| ('is_binary', str(False)), |
| ('is_current', str(False)), |
| ] |
| uploaded_diff_file = [('data', config_path, base_content)] |
| ctype, body = self._EncodeMultipartFormData(form_fields, uploaded_diff_file) |
| response = self.MakeRequest( |
| request_path, method='POST', body=body, headers={'content-type': ctype}) |
| if response.status_code != 200: |
| logging.error( |
| 'Error %s uploading to %s', response.status_code, request_path) |
| logging.error(response.content) |
| return (None, None) |
| |
| request_path = '%s/upload_complete/%s' % (issue_id, patchset_id) |
| response = self.MakeRequest(request_path, method='POST') |
| if response.status_code != 200: |
| logging.error( |
| 'Error %s uploading to %s', response.status_code, request_path) |
| logging.error(response.content) |
| return (None, None) |
| return issue_id, patchset_id |
| |
| def TryPatch(self, tryserver_master, issue_id, patchset_id, bot): |
| """Sends a request to try the given patchset on the given trybot. |
| |
| To see exactly how this request is handled, you can see the try_patchset |
| handler in the Chromium branch of Rietveld: http://goo.gl/U6tJQZ |
| |
| Args: |
| tryserver_master: Master name, e.g. "tryserver.chromium.perf". |
| issue_id: Rietveld issue ID. |
| patchset_id: Patchset ID (returned when a patch is uploaded). |
| bot: Bisect bot name. |
| |
| Returns: |
| True if successful, False otherwise. |
| """ |
| args = { |
| 'xsrf_token': self._XsrfToken(), |
| 'builders': json.dumps({bot: ['defaulttests']}), |
| 'master': tryserver_master, |
| 'reason': 'Perf bisect', |
| 'clobber': 'False', |
| } |
| request_path = '%s/try/%s' % (issue_id, patchset_id) |
| response = self.MakeRequest( |
| request_path, method='POST', body=urllib.urlencode(args)) |
| if response.status_code != 200: |
| logging.error( |
| 'Error %s POSTing to /%s/try/%s', response.status_code, issue_id, |
| patchset_id) |
| logging.error(response.content) |
| return False |
| return True |