Upstream version 8.36.161.0
[platform/framework/web/crosswalk.git] / src / third_party / chromite / lib / gob_util.py
1 # Copyright (c) 2013 The Chromium OS 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 """Utilities for requesting information for a gerrit server via https.
6
7 https://gerrit-review.googlesource.com/Documentation/rest-api.html
8 """
9
10 import base64
11 import httplib
12 import json
13 import logging
14 import netrc
15 import os
16 import socket
17 import urllib
18 from cStringIO import StringIO
19
20 from chromite.lib import retry_util
21
22
23 try:
24   NETRC = netrc.netrc()
25 except (IOError, netrc.NetrcParseError):
26   NETRC = netrc.netrc(os.devnull)
27 LOGGER = logging.getLogger()
28 TRY_LIMIT = 10
29 SLEEP = 0.5
30
31 # Controls the transport protocol used to communicate with gerrit.
32 # This is parameterized primarily to enable cros_test_lib.GerritTestCase.
33 GERRIT_PROTOCOL = 'https'
34
35
36 class GOBError(Exception):
37   """Exception class for errors commuicating with the gerrit-on-borg service."""
38   def __init__(self, http_status, *args, **kwargs):
39     super(GOBError, self).__init__(*args, **kwargs)
40     self.http_status = http_status
41     self.message = '(%d) %s' % (self.http_status, self.message)
42
43
44 class InternalGOBError(GOBError):
45   """Exception class for GOB errors with status >= 500"""
46
47
48 def _QueryString(param_dict, first_param=None):
49   """Encodes query parameters in the key:val[+key:val...] format specified here:
50
51   https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
52   """
53   q = [urllib.quote(first_param)] if first_param else []
54   q.extend(['%s:%s' % (key, val) for key, val in param_dict.iteritems()])
55   return '+'.join(q)
56
57
58 def CreateHttpConn(host, path, reqtype='GET', headers=None, body=None):
59   """Opens an https connection to a gerrit service, and sends a request."""
60   headers = headers or {}
61   bare_host = host.partition(':')[0]
62   auth = NETRC.authenticators(bare_host)
63   if auth:
64     headers.setdefault('Authorization', 'Basic %s' % (
65         base64.b64encode('%s:%s' % (auth[0], auth[2]))))
66   else:
67     LOGGER.debug('No authorization found')
68   if body:
69     body = json.JSONEncoder().encode(body)
70     headers.setdefault('Content-Type', 'application/json')
71   if LOGGER.isEnabledFor(logging.DEBUG):
72     LOGGER.debug('%s %s://%s/a/%s' % (reqtype, GERRIT_PROTOCOL, host, path))
73     for key, val in headers.iteritems():
74       if key == 'Authorization':
75         val = 'HIDDEN'
76       LOGGER.debug('%s: %s' % (key, val))
77     if body:
78       LOGGER.debug(body)
79   conn = httplib.HTTPSConnection(host)
80   conn.req_host = host
81   conn.req_params = {
82       'url': '/a/%s' % path,
83       'method': reqtype,
84       'headers': headers,
85       'body': body,
86   }
87   conn.request(**conn.req_params)
88   return conn
89
90
91 def FetchUrl(host, path, reqtype='GET', headers=None, body=None,
92              ignore_404=True):
93   """Fetches the http response from the specified URL into a string buffer.
94
95   Args:
96     host: The hostname of the Gerrit service.
97     path: The path on the Gerrit service. This will be prefixed with '/a'
98           automatically.
99     reqtype: The request type. Can be GET or POST.
100     headers: A mapping of extra HTTP headers to pass in with the request.
101     body: A string of data to send after the headers are finished.
102     ignore_404: For many requests, gerrit-on-borg will return 404 if the request
103                 doesn't match the database contents.  In most such cases, we
104                 want the API to return None rather than raise an Exception.
105
106   Returns:
107     A string buffer containing the connection's reply.
108   """
109   def _FetchUrlHelper():
110     err_prefix = 'A transient error occured while querying %s:\n' % (host,)
111     try:
112       conn = CreateHttpConn(host, path, reqtype=reqtype, headers=headers,
113                             body=body)
114       response = conn.getresponse()
115     except socket.error as ex:
116       LOGGER.warn('%s%s', err_prefix, str(ex))
117       raise
118
119     # Normal/good responses.
120     if response.status == 404 and ignore_404:
121       return StringIO()
122     elif response.status == 200:
123       return StringIO(response.read())
124
125     # Bad responses.
126     LOGGER.debug('response msg:\n%s', response.msg)
127     http_version = 'HTTP/%s' % ('1.1' if response.version == 11 else '1.0')
128     msg = ('%s %s %s\n%s %d %s' %
129            (reqtype, conn.req_params['url'], http_version,
130             http_version, response.status, response.reason))
131
132     # Ones we can retry.
133     if response.status >= 500:
134       # A status >=500 is assumed to be a possible transient error; retry.
135       LOGGER.warn('%s%s', err_prefix, msg)
136       raise InternalGOBError(response.status, response.reason)
137
138     # Ones we cannot retry.
139     home = os.environ.get('HOME', '~')
140     url = 'https://%s/new-password' % host
141     if response.status in (302, 303, 307):
142       err_prefix = ('Redirect found; missing/bad %s/.netrc credentials?\n'
143                     ' See %s' % (home, url))
144     elif response.status in (400,):
145       err_prefix = 'Permission error; talk to the admins of the GoB instance'
146     elif response.status in (401,):
147       err_prefix = ('Authorization error; missing/bad %s/.netrc credentials?\n'
148                     ' See %s' % (home, url))
149     elif response.status in (422,):
150       err_prefix = ('Bad request body?  Response body: "%s"' % response.read())
151
152     if response.status >= 400:
153       # The 'X-ErrorId' header is set only on >= 400 response code.
154       LOGGER.warn('%s\n%s\nX-ErrorId: %s', err_prefix, msg,
155                   response.getheader('X-ErrorId'))
156     else:
157       LOGGER.warn('%s\n%s', err_prefix, msg)
158
159     LOGGER.warn('conn.sock.getpeername(): %s', conn.sock.getpeername())
160     raise GOBError(response.status, response.reason)
161
162   return retry_util.RetryException((socket.error, InternalGOBError), TRY_LIMIT,
163                                    _FetchUrlHelper, sleep=SLEEP)
164
165
166 def FetchUrlJson(*args, **kwargs):
167   """Fetch the specified URL and parse it as JSON.
168
169   See FetchUrl for arguments.
170   """
171   fh = FetchUrl(*args, **kwargs)
172   # The first line of the response should always be: )]}'
173   s = fh.readline()
174   if s and s.rstrip() != ")]}'":
175     raise GOBError(200, 'Unexpected json output: %s' % s)
176   s = fh.read()
177   if not s:
178     return None
179   return json.loads(s)
180
181
182 def QueryChanges(host, param_dict, first_param=None, limit=None, o_params=None,
183                  sortkey=None):
184   """Queries a gerrit-on-borg server for changes matching query terms.
185
186   Args:
187     host: The Gerrit server hostname.
188     param_dict: A dictionary of search parameters, as documented here:
189         http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/user-search.html
190     first_param: A change identifier
191     limit: Maximum number of results to return.
192     o_params: A list of additional output specifiers, as documented here:
193         https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
194     sortkey: Positions the low level scan routine to start from this |sortkey|
195         and continue through changes from this point. This is most often used
196         for paginating result sets.
197
198   Returns:
199     A list of json-decoded query results.
200   """
201   # Note that no attempt is made to escape special characters; YMMV.
202   if not param_dict and not first_param:
203     raise RuntimeError('QueryChanges requires search parameters')
204   path = 'changes/?q=%s' % _QueryString(param_dict, first_param)
205   if sortkey:
206     path = '%s&N=%s' % (path, sortkey)
207   if limit:
208     path = '%s&n=%d' % (path, limit)
209   if o_params:
210     path = '%s&%s' % (path, '&'.join(['o=%s' % p for p in o_params]))
211   # Don't ignore 404; a query should always return a list, even if it's empty.
212   return FetchUrlJson(host, path, ignore_404=False)
213
214
215 def MultiQueryChanges(host, param_dict, change_list, limit=None, o_params=None,
216                       sortkey=None):
217   """Initiate a query composed of multiple sets of query parameters."""
218   if not change_list:
219     raise RuntimeError(
220         "MultiQueryChanges requires a list of change numbers/id's")
221   q = ['q=%s' % '+OR+'.join([urllib.quote(str(x)) for x in change_list])]
222   if param_dict:
223     q.append(_QueryString(param_dict))
224   if limit:
225     q.append('n=%d' % limit)
226   if sortkey:
227     q.append('N=%s' % sortkey)
228   if o_params:
229     q.extend(['o=%s' % p for p in o_params])
230   path = 'changes/?%s' % '&'.join(q)
231   try:
232     result = FetchUrlJson(host, path, ignore_404=False)
233   except GOBError as e:
234     msg = '%s:\n%s' % (e.message, path)
235     raise GOBError(e.http_status, msg)
236   return result
237
238
239 def GetGerritFetchUrl(host):
240   """Given a gerrit host name returns URL of a gerrit instance to fetch from."""
241   return '%s://%s/' % (GERRIT_PROTOCOL, host)
242
243
244 def GetChangePageUrl(host, change_number):
245   """Given a gerrit host name and change number, return change page url."""
246   return '%s://%s/#/c/%d/' % (GERRIT_PROTOCOL, host, change_number)
247
248
249 def GetChangeUrl(host, change):
250   """Given a gerrit host name and change id, return an url for the change."""
251   return '%s://%s/a/changes/%s' % (GERRIT_PROTOCOL, host, change)
252
253
254 def GetChange(host, change):
255   """Query a gerrit server for information about a single change."""
256   path = 'changes/%s' % change
257   return FetchUrlJson(host, path)
258
259
260 def GetChangeReview(host, change, revision='current'):
261   """Get the current review information for a change."""
262   path = 'changes/%s/revisions/%s/review' % (change, revision)
263   return FetchUrlJson(host, path)
264
265
266 def GetChangeCommit(host, change, revision='current'):
267   """Get the current review information for a change."""
268   path = 'changes/%s/revisions/%s/commit' % (change, revision)
269   return FetchUrlJson(host, path)
270
271
272 def GetChangeCurrentRevision(host, change):
273   """Get information about the latest revision for a given change."""
274   jmsg = GetChangeReview(host, change)
275   if jmsg:
276     return jmsg.get('current_revision')
277
278
279 def GetChangeDetail(host, change, o_params=None):
280   """Query a gerrit server for extended information about a single change."""
281   path = 'changes/%s/detail' % change
282   if o_params:
283     path = '%s?%s' % (path, '&'.join(['o=%s' % p for p in o_params]))
284   return FetchUrlJson(host, path)
285
286
287 def GetChangeReviewers(host, change):
288   """Get information about all reviewers attached to a change."""
289   path = 'changes/%s/reviewers' % change
290   return FetchUrlJson(host, path)
291
292
293 def AbandonChange(host, change, msg=''):
294   """Abandon a gerrit change."""
295   path = 'changes/%s/abandon' % change
296   body = {'message': msg} if msg else None
297   return FetchUrlJson(host, path, reqtype='POST', body=body, ignore_404=False)
298
299
300 def RestoreChange(host, change, msg=''):
301   """Restore a previously abandoned change."""
302   path = 'changes/%s/restore' % change
303   body = {'message': msg} if msg else None
304   return FetchUrlJson(host, path, reqtype='POST', body=body, ignore_404=False)
305
306
307 def DeleteDraft(host, change, msg=''):
308   """Delete a gerrit draft patch set."""
309   path = 'changes/%s' % change
310   body = {'message': msg} if msg else None
311   try:
312     FetchUrl(host, path, reqtype='DELETE', body=body, ignore_404=False)
313   except GOBError as e:
314     # On success, gerrit returns status 204; anything else is an error.
315     if e.http_status != 204:
316       raise
317   else:
318     raise GOBError(
319         'Unexpectedly received a 200 http status while deleting draft %r'
320         % change)
321
322
323 def SubmitChange(host, change, wait_for_merge=True):
324   """Submits a gerrit change via Gerrit."""
325   path = 'changes/%s/submit' % change
326   body = {'wait_for_merge': wait_for_merge}
327   return FetchUrlJson(host, path, reqtype='POST', body=body, ignore_404=False)
328
329
330 def GetReviewers(host, change):
331   """Get information about all reviewers attached to a change."""
332   path = 'changes/%s/reviewers' % change
333   return FetchUrlJson(host, path)
334
335
336 def AddReviewers(host, change, add=None):
337   """Add reviewers to a change."""
338   if not add:
339     return
340   if isinstance(add, basestring):
341     add = (add,)
342   path = 'changes/%s/reviewers' % change
343   for r in add:
344     body = {'reviewer': r}
345     jmsg = FetchUrlJson(host, path, reqtype='POST', body=body, ignore_404=False)
346   return jmsg
347
348
349 def RemoveReviewers(host, change, remove=None):
350   """Remove reveiewers from a change."""
351   if not remove:
352     return
353   if isinstance(remove, basestring):
354     remove = (remove,)
355   for r in remove:
356     path = 'changes/%s/reviewers/%s' % (change, r)
357     try:
358       FetchUrl(host, path, reqtype='DELETE', ignore_404=False)
359     except GOBError as e:
360       # On success, gerrit returns status 204; anything else is an error.
361       if e.http_status != 204:
362         raise
363     else:
364       raise GOBError(
365           'Unexpectedly received a 200 http status while deleting reviewer "%s"'
366           ' from change %s' % (r, change))
367
368
369 def SetReview(host, change, revision='current', msg=None, labels=None,
370               notify=None):
371   """Set labels and/or add a message to a code review."""
372   if not msg and not labels:
373     return
374   path = 'changes/%s/revisions/%s/review' % (change, revision)
375   body = {}
376   if msg:
377     body['message'] = msg
378   if labels:
379     body['labels'] = labels
380   if notify:
381     body['notify'] = notify
382   response = FetchUrlJson(host, path, reqtype='POST', body=body)
383   if labels:
384     for key, val in labels.iteritems():
385       if ('labels' not in response or key not in response['labels'] or
386           int(response['labels'][key] != int(val))):
387         raise GOBError(200, 'Unable to set "%s" label on change %s.' % (
388             key, change))
389
390
391 def ResetReviewLabels(host, change, label, value='0', revision='current',
392                       message=None, notify=None):
393   """Reset the value of a given label for all reviewers on a change."""
394   # This is tricky when working on the "current" revision, because there's
395   # always the risk that the "current" revision will change in between API
396   # calls.  So, the code dereferences the "current" revision down to a literal
397   # sha1 at the beginning and uses it for all subsequent calls.  As a sanity
398   # check, the "current" revision is dereferenced again at the end, and if it
399   # differs from the previous "current" revision, an exception is raised.
400   current = (revision == 'current')
401   jmsg = GetChangeDetail(
402       host, change, o_params=['CURRENT_REVISION', 'CURRENT_COMMIT'])
403   if current:
404     revision = jmsg['current_revision']
405   value = str(value)
406   path = 'changes/%s/revisions/%s/review' % (change, revision)
407   message = message or (
408       '%s label set to %s programmatically by chromite.' % (label, value))
409   for review in jmsg.get('labels', {}).get(label, {}).get('all', []):
410     if str(review.get('value', value)) != value:
411       body = {
412           'message': message,
413           'labels': {label: value},
414           'on_behalf_of': review['_account_id'],
415       }
416       if notify:
417         body['notify'] = notify
418       response = FetchUrlJson(host, path, reqtype='POST', body=body)
419       if str(response['labels'][label]) != value:
420         username = review.get('email', jmsg.get('name', ''))
421         raise GOBError(200, 'Unable to set %s label for user "%s"'
422                        ' on change %s.' % (label, username, change))
423   if current:
424     new_revision = GetChangeCurrentRevision(host, change)
425     if not new_revision:
426       raise GOBError(
427           200, 'Could not get review information for change "%s"' % change)
428     elif new_revision != revision:
429       raise GOBError(200, 'While resetting labels on change "%s", '
430                      'a new patchset was uploaded.' % change)