rietveld_service.py revision 4a4f2fe02baf385f6c24fc98c6e17bf6ac5e0724
1# Copyright 2015 The Chromium Authors. All rights reserved.
2# Use of this source code is governed by a BSD-style license that can be
3# found in the LICENSE file.
4
5"""Defines common functionality used for interacting with Rietveld."""
6
7import json
8import logging
9import mimetypes
10import urllib
11
12import httplib2
13from oauth2client import client
14
15from google.appengine.ext import ndb
16
17_EMAIL_SCOPE = 'https://www.googleapis.com/auth/userinfo.email'
18PROJECTHOSTING_SCOPE = 'https://www.googleapis.com/auth/projecthosting'
19
20_DESCRIPTION = """This patch was automatically uploaded by the Chrome Perf
21Dashboard (https://chromeperf.appspot.com). It is being used to run a perf
22bisect try job. It should not be submitted."""
23
24
25class RietveldConfig(ndb.Model):
26  """Configuration info for a Rietveld service account.
27
28  The data is stored only in the App Engine datastore (and the cloud console)
29  and not the code because it contains sensitive information like private keys.
30  """
31  client_email = ndb.TextProperty()
32  service_account_key = ndb.TextProperty()
33
34  # The protocol and domain of the Rietveld host. Should not contain path.
35  server_url = ndb.TextProperty()
36
37  # The protocol and domain of the Internal Rietveld host which is used
38  # to create issues for internal only tests.
39  internal_server_url = ndb.TextProperty()
40
41
42def Credentials(config, scope):
43  """Returns a credentials object used to authenticate a Http object."""
44  return client.SignedJwtAssertionCredentials(
45      config.client_email, config.service_account_key, scope)
46
47
48def GetDefaultRietveldConfig():
49  """Returns the default rietveld config entity from the datastore."""
50  return ndb.Key(RietveldConfig, 'default_rietveld_config').get()
51
52
53class RietveldService(object):
54  """Implements a Python API to Rietveld via HTTP.
55
56  Authentication is handled via an OAuth2 access token minted from an RSA key
57  associated with a service account (which can be created via the Google API
58  console). For this to work, the Rietveld instance to talk to must be
59  configured to allow the service account client ID as OAuth2 audience (see
60  Rietveld source). Both the RSA key and the server URL are provided via static
61  application configuration.
62  """
63
64  def __init__(self, internal_only=False):
65    self.internal_only = internal_only
66    self._config = None
67    self._http = None
68
69  def Config(self):
70    if not self._config:
71      self._config = GetDefaultRietveldConfig()
72    return self._config
73
74  def _Http(self):
75    if not self._http:
76      self._http = httplib2.Http()
77      creds = Credentials(self.Config(), _EMAIL_SCOPE)
78      creds.authorize(self._http)
79    return self._http
80
81  def _XsrfToken(self):
82    """Requests a XSRF token from Rietveld."""
83    return self._MakeRequest(
84        'xsrf_token', headers={'X-Requesting-XSRF-Token': 1})[1]
85
86  def _MakeRequest(self, path, *args, **kwwargs):
87    """Makes a request to the Rietveld server."""
88    if self.internal_only:
89      server_url = self.Config().internal_server_url
90    else:
91      server_url = self.Config().server_url
92    url = '%s/%s' % (server_url, path)
93    response, content = self._Http().request(url, *args, **kwwargs)
94    return (response, content)
95
96  def _EncodeMultipartFormData(self, fields, files):
97    """Encode form fields for multipart/form-data.
98
99    Args:
100      fields: A sequence of (name, value) elements for regular form fields.
101      files: A sequence of (name, filename, value) elements for data to be
102             uploaded as files.
103    Returns:
104      (content_type, body) ready for httplib.HTTP instance.
105
106    Source:
107      http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/146306
108    """
109    boundary = '-M-A-G-I-C---B-O-U-N-D-A-R-Y-'
110    crlf = '\r\n'
111    lines = []
112    for (key, value) in fields:
113      lines.append('--' + boundary)
114      lines.append('Content-Disposition: form-data; name="%s"' % key)
115      lines.append('')
116      if isinstance(value, unicode):
117        value = value.encode('utf-8')
118      lines.append(value)
119    for (key, filename, value) in files:
120      lines.append('--' + boundary)
121      lines.append('Content-Disposition: form-data; name="%s"; filename="%s"' %
122                   (key, filename))
123      content_type = (mimetypes.guess_type(filename)[0] or
124                      'application/octet-stream')
125      lines.append('Content-Type: %s' % content_type)
126      lines.append('')
127      if isinstance(value, unicode):
128        value = value.encode('utf-8')
129      lines.append(value)
130    lines.append('--' + boundary + '--')
131    lines.append('')
132    body = crlf.join(lines)
133    content_type = 'multipart/form-data; boundary=%s' % boundary
134    return content_type, body
135
136  def UploadPatch(self, subject, patch, base_checksum, base_hashes,
137                  base_content, config_path):
138    """Uploads the given patch file contents to Rietveld.
139
140    The process of creating an issue and uploading the patch requires several
141    HTTP requests to Rietveld.
142
143    Rietveld API documentation: https://code.google.com/p/rietveld/wiki/APIs
144    For specific implementation in Rietveld codebase, see http://goo.gl/BW205J.
145
146    Args:
147      subject: Title of the job, as it will appear in rietveld.
148      patch: The patch, which is a specially-formatted string.
149      base_checksum: Base md5 checksum to send.
150      base_hashes: "Base hashes" string to send.
151      base_content: Base config file contents.
152      config_path: Path to the config file.
153
154    Returns:
155      A (issue ID, patchset ID) pair. These are strings that contain numerical
156      IDs. If the patch upload was unsuccessful, then (None, None) is returned.
157    """
158    base = 'https://chromium.googlesource.com/chromium/src.git@master'
159    repo_guid = 'c14d891d44f0afff64e56ed7c9702df1d807b1ee'
160    form_fields = [
161        ('subject', subject),
162        ('description', _DESCRIPTION),
163        ('base', base),
164        ('xsrf_token', self._XsrfToken()),
165        ('repo_guid', repo_guid),
166        ('content_upload', '1'),
167        ('base_hashes', base_hashes),
168    ]
169    uploaded_diff_file = [('data', 'data.diff', patch)]
170    ctype, body = self._EncodeMultipartFormData(
171        form_fields, uploaded_diff_file)
172    response, content = self._MakeRequest(
173        'upload', method='POST', body=body, headers={'content-type': ctype})
174    if response.get('status') != '200':
175      logging.error('Error %s uploading to /upload', response.get('status'))
176      logging.error(content)
177      return (None, None)
178
179    # There should always be 3 lines in the request, but sometimes Rietveld
180    # returns 2 lines. Log the content so we can debug further.
181    logging.info('Response from Rietveld /upload:\n%s', content)
182    if not content.startswith('Issue created.'):
183      logging.error('Unexpected response: %s', content)
184      return (None, None)
185    lines = content.splitlines()
186    if len(lines) < 2:
187      logging.error('Unexpected response %s', content)
188      return (None, None)
189
190    msg = lines[0]
191    issue_id = msg[msg.rfind('/')+1:]
192    patchset_id = lines[1].strip()
193    patches = [x.split(' ', 1) for x in lines[2:]]
194    request_path = '%d/upload_content/%d/%d' % (
195        int(issue_id), int(patchset_id), int(patches[0][0]))
196    form_fields = [
197        ('filename', config_path),
198        ('status', 'M'),
199        ('checksum', base_checksum),
200        ('is_binary', str(False)),
201        ('is_current', str(False)),
202    ]
203    uploaded_diff_file = [('data', config_path, base_content)]
204    ctype, body = self._EncodeMultipartFormData(form_fields, uploaded_diff_file)
205    response, content = self._MakeRequest(
206        request_path, method='POST', body=body, headers={'content-type': ctype})
207    if response.get('status') != '200':
208      logging.error(
209          'Error %s uploading to %s', response.get('status'), request_path)
210      logging.error(content)
211      return (None, None)
212
213    request_path = '%s/upload_complete/%s' % (issue_id, patchset_id)
214    response, content = self._MakeRequest(request_path, method='POST')
215    if response.get('status') != '200':
216      logging.error(
217          'Error %s uploading to %s', response.get('status'), request_path)
218      logging.error(content)
219      return (None, None)
220    return issue_id, patchset_id
221
222  def TryPatch(self, tryserver_master, issue_id, patchset_id, bot):
223    """Sends a request to try the given patchset on the given trybot.
224
225    To see exactly how this request is handled, you can see the try_patchset
226    handler in the Chromium branch of Rietveld: http://goo.gl/U6tJQZ
227
228    Args:
229      tryserver_master: Master name, e.g. "tryserver.chromium.perf".
230      issue_id: Rietveld issue ID.
231      patchset_id: Patchset ID (returned when a patch is uploaded).
232      bot: Bisect bot name.
233
234    Returns:
235      True if successful, False otherwise.
236    """
237    args = {
238        'xsrf_token': self._XsrfToken(),
239        'builders': json.dumps({bot: ['defaulttests']}),
240        'master': tryserver_master,
241        'reason': 'Perf bisect',
242        'clobber': 'False',
243    }
244    request_path = '%s/try/%s' % (issue_id, patchset_id)
245    response, content = self._MakeRequest(
246        request_path, method='POST', body=urllib.urlencode(args))
247    if response.get('status') != '200':
248      status = response.get('status')
249      logging.error(
250          'Error %s POSTing to /%s/try/%s', status, issue_id, patchset_id)
251      logging.error(content)
252      return False
253    return True
254