blob: 691daf9182989a93339e8ed4245b4e8f326713e1 [file] [log] [blame]
szager@chromium.orgb4696232013-10-16 19:45:35 +00001# 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"""
6Utilities for requesting information for a gerrit server via https.
7
8https://gerrit-review.googlesource.com/Documentation/rest-api.html
9"""
10
11import base64
12import httplib
13import json
14import logging
15import netrc
16import os
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +000017import re
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000018import socket
szager@chromium.orgf202a252014-05-27 18:55:52 +000019import stat
20import sys
szager@chromium.orgb4696232013-10-16 19:45:35 +000021import time
22import urllib
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000023import urlparse
szager@chromium.orgb4696232013-10-16 19:45:35 +000024from cStringIO import StringIO
25
szager@chromium.orgf202a252014-05-27 18:55:52 +000026
szager@chromium.orgb4696232013-10-16 19:45:35 +000027LOGGER = logging.getLogger()
28TRY_LIMIT = 5
29
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000030
szager@chromium.orgb4696232013-10-16 19:45:35 +000031# Controls the transport protocol used to communicate with gerrit.
32# This is parameterized primarily to enable GerritTestCase.
33GERRIT_PROTOCOL = 'https'
34
35
36class GerritError(Exception):
37 """Exception class for errors commuicating with the gerrit-on-borg service."""
38 def __init__(self, http_status, *args, **kwargs):
39 super(GerritError, self).__init__(*args, **kwargs)
40 self.http_status = http_status
41 self.message = '(%d) %s' % (self.http_status, self.message)
42
43
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +000044class GerritAuthenticationError(GerritError):
45 """Exception class for authentication errors during Gerrit communication."""
46
47
szager@chromium.orgb4696232013-10-16 19:45:35 +000048def _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
58def GetConnectionClass(protocol=None):
59 if protocol is None:
60 protocol = GERRIT_PROTOCOL
61 if protocol == 'https':
62 return httplib.HTTPSConnection
63 elif protocol == 'http':
64 return httplib.HTTPConnection
65 else:
66 raise RuntimeError(
67 "Don't know how to work with protocol '%s'" % protocol)
68
69
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +000070class Authenticator(object):
71 """Base authenticator class for authenticator implementations to subclass."""
72
73 def get_auth_header(self, host):
74 raise NotImplementedError()
75
76 @staticmethod
77 def get():
78 """Returns: (Authenticator) The identified Authenticator to use.
79
80 Probes the local system and its environment and identifies the
81 Authenticator instance to use.
82 """
83 if GceAuthenticator.is_gce():
84 return GceAuthenticator()
85 return NetrcAuthenticator()
86
87
88class NetrcAuthenticator(Authenticator):
89 """Authenticator implementation that uses ".netrc" for token.
90 """
91
92 def __init__(self):
93 self.netrc = self._get_netrc()
94
95 @staticmethod
96 def _get_netrc():
97 path = '_netrc' if sys.platform.startswith('win') else '.netrc'
98 path = os.path.join(os.environ['HOME'], path)
99 try:
100 return netrc.netrc(path)
101 except IOError:
102 print >> sys.stderr, 'WARNING: Could not read netrc file %s' % path
103 return netrc.netrc(os.devnull)
104 except netrc.NetrcParseError as e:
105 st = os.stat(e.path)
106 if st.st_mode & (stat.S_IRWXG | stat.S_IRWXO):
107 print >> sys.stderr, (
108 'WARNING: netrc file %s cannot be used because its file '
109 'permissions are insecure. netrc file permissions should be '
110 '600.' % path)
111 else:
112 print >> sys.stderr, ('ERROR: Cannot use netrc file %s due to a '
113 'parsing error.' % path)
114 raise
115 return netrc.netrc(os.devnull)
116
117 def get_auth_header(self, host):
118 auth = self.netrc.authenticators(host)
119 if auth:
120 return 'Basic %s' % (base64.b64encode('%s:%s' % (auth[0], auth[2])))
121 return None
122
123
124class GceAuthenticator(Authenticator):
125 """Authenticator implementation that uses GCE metadata service for token.
126 """
127
128 _INFO_URL = 'http://metadata.google.internal'
129 _ACQUIRE_URL = ('http://metadata/computeMetadata/v1/instance/'
130 'service-accounts/default/token')
131 _ACQUIRE_HEADERS = {"Metadata-Flavor": "Google"}
132
133 _cache_is_gce = None
134 _token_cache = None
135 _token_expiration = None
136
137 @classmethod
138 def is_gce(cls):
139 if cls._cache_is_gce is None:
140 cls._cache_is_gce = cls._test_is_gce()
141 return cls._cache_is_gce
142
143 @classmethod
144 def _test_is_gce(cls):
145 # Based on https://cloud.google.com/compute/docs/metadata#runninggce
146 try:
147 resp = cls._get(cls._INFO_URL)
148 except socket.error:
149 # Could not resolve URL.
150 return False
151 return resp.getheader('Metadata-Flavor', None) == 'Google'
152
153 @staticmethod
154 def _get(url, **kwargs):
155 next_delay_sec = 1
156 for i in xrange(TRY_LIMIT):
157 if i > 0:
158 # Retry server error status codes.
159 LOGGER.info('Encountered server error; retrying after %d second(s).',
160 next_delay_sec)
161 time.sleep(next_delay_sec)
162 next_delay_sec *= 2
163
164 p = urlparse.urlparse(url)
165 c = GetConnectionClass(protocol=p.scheme)(p.netloc)
166 c.request('GET', url, **kwargs)
167 resp = c.getresponse()
168 LOGGER.debug('GET [%s] #%d/%d (%d)', url, i+1, TRY_LIMIT, resp.status)
169 if resp.status < httplib.INTERNAL_SERVER_ERROR:
170 return resp
171
172
173 @classmethod
174 def _get_token_dict(cls):
175 if cls._token_cache:
176 # If it expires within 25 seconds, refresh.
177 if cls._token_expiration < time.time() - 25:
178 return cls._token_cache
179
180 resp = cls._get(cls._ACQUIRE_URL, headers=cls._ACQUIRE_HEADERS)
181 if resp.status != httplib.OK:
182 return None
183 cls._token_cache = json.load(resp)
184 cls._token_expiration = cls._token_cache['expires_in'] + time.time()
185 return cls._token_cache
186
187 def get_auth_header(self, _host):
188 token_dict = self._get_token_dict()
189 if not token_dict:
190 return None
191 return '%(token_type)s %(access_token)s' % token_dict
192
193
194
szager@chromium.orgb4696232013-10-16 19:45:35 +0000195def CreateHttpConn(host, path, reqtype='GET', headers=None, body=None):
196 """Opens an https connection to a gerrit service, and sends a request."""
197 headers = headers or {}
198 bare_host = host.partition(':')[0]
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000199
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000200 auth = Authenticator.get().get_auth_header(bare_host)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000201 if auth:
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000202 headers.setdefault('Authorization', auth)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000203 else:
dnj@chromium.orga5a2c8a2015-09-29 16:22:55 +0000204 LOGGER.debug('No authorization found for %s.' % bare_host)
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000205
206 if 'Authorization' in headers and not path.startswith('a/'):
207 url = '/a/%s' % path
208 else:
209 url = '/%s' % path
210
szager@chromium.orgb4696232013-10-16 19:45:35 +0000211 if body:
212 body = json.JSONEncoder().encode(body)
213 headers.setdefault('Content-Type', 'application/json')
214 if LOGGER.isEnabledFor(logging.DEBUG):
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000215 LOGGER.debug('%s %s://%s%s' % (reqtype, GERRIT_PROTOCOL, host, url))
szager@chromium.orgb4696232013-10-16 19:45:35 +0000216 for key, val in headers.iteritems():
217 if key == 'Authorization':
218 val = 'HIDDEN'
219 LOGGER.debug('%s: %s' % (key, val))
220 if body:
221 LOGGER.debug(body)
222 conn = GetConnectionClass()(host)
223 conn.req_host = host
224 conn.req_params = {
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000225 'url': url,
szager@chromium.orgb4696232013-10-16 19:45:35 +0000226 'method': reqtype,
227 'headers': headers,
228 'body': body,
229 }
230 conn.request(**conn.req_params)
231 return conn
232
233
234def ReadHttpResponse(conn, expect_status=200, ignore_404=True):
235 """Reads an http response from a connection into a string buffer.
236
237 Args:
238 conn: An HTTPSConnection or HTTPConnection created by CreateHttpConn, above.
239 expect_status: Success is indicated by this status in the response.
240 ignore_404: For many requests, gerrit-on-borg will return 404 if the request
241 doesn't match the database contents. In most such cases, we
242 want the API to return None rather than raise an Exception.
243 Returns: A string buffer containing the connection's reply.
244 """
245
246 sleep_time = 0.5
247 for idx in range(TRY_LIMIT):
248 response = conn.getresponse()
nodir@chromium.orgce32b6e2014-05-12 20:31:32 +0000249
250 # Check if this is an authentication issue.
251 www_authenticate = response.getheader('www-authenticate')
252 if (response.status in (httplib.UNAUTHORIZED, httplib.FOUND) and
253 www_authenticate):
254 auth_match = re.search('realm="([^"]+)"', www_authenticate, re.I)
255 host = auth_match.group(1) if auth_match else conn.req_host
256 reason = ('Authentication failed. Please make sure your .netrc file '
257 'has credentials for %s' % host)
258 raise GerritAuthenticationError(response.status, reason)
259
szager@chromium.orgb4696232013-10-16 19:45:35 +0000260 # If response.status < 500 then the result is final; break retry loop.
261 if response.status < 500:
262 break
263 # A status >=500 is assumed to be a possible transient error; retry.
264 http_version = 'HTTP/%s' % ('1.1' if response.version == 11 else '1.0')
265 msg = (
266 'A transient error occured while querying %s:\n'
267 '%s %s %s\n'
268 '%s %d %s' % (
269 conn.host, conn.req_params['method'], conn.req_params['url'],
270 http_version, http_version, response.status, response.reason))
271 if TRY_LIMIT - idx > 1:
272 msg += '\n... will retry %d more times.' % (TRY_LIMIT - idx - 1)
273 time.sleep(sleep_time)
274 sleep_time = sleep_time * 2
275 req_host = conn.req_host
276 req_params = conn.req_params
277 conn = GetConnectionClass()(req_host)
278 conn.req_host = req_host
279 conn.req_params = req_params
280 conn.request(**req_params)
281 LOGGER.warn(msg)
282 if ignore_404 and response.status == 404:
283 return StringIO()
284 if response.status != expect_status:
nodir@chromium.orga7798032014-04-30 23:40:53 +0000285 reason = '%s: %s' % (response.reason, response.read())
286 raise GerritError(response.status, reason)
szager@chromium.orgb4696232013-10-16 19:45:35 +0000287 return StringIO(response.read())
288
289
290def ReadHttpJsonResponse(conn, expect_status=200, ignore_404=True):
291 """Parses an https response as json."""
292 fh = ReadHttpResponse(
293 conn, expect_status=expect_status, ignore_404=ignore_404)
294 # The first line of the response should always be: )]}'
295 s = fh.readline()
296 if s and s.rstrip() != ")]}'":
297 raise GerritError(200, 'Unexpected json output: %s' % s)
298 s = fh.read()
299 if not s:
300 return None
301 return json.loads(s)
302
303
304def QueryChanges(host, param_dict, first_param=None, limit=None, o_params=None,
305 sortkey=None):
306 """
307 Queries a gerrit-on-borg server for changes matching query terms.
308
309 Args:
310 param_dict: A dictionary of search parameters, as documented here:
311 http://gerrit-documentation.googlecode.com/svn/Documentation/2.6/user-search.html
312 first_param: A change identifier
313 limit: Maximum number of results to return.
314 o_params: A list of additional output specifiers, as documented here:
315 https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#list-changes
316 Returns:
317 A list of json-decoded query results.
318 """
319 # Note that no attempt is made to escape special characters; YMMV.
320 if not param_dict and not first_param:
321 raise RuntimeError('QueryChanges requires search parameters')
322 path = 'changes/?q=%s' % _QueryString(param_dict, first_param)
323 if sortkey:
324 path = '%s&N=%s' % (path, sortkey)
325 if limit:
326 path = '%s&n=%d' % (path, limit)
327 if o_params:
328 path = '%s&%s' % (path, '&'.join(['o=%s' % p for p in o_params]))
329 # Don't ignore 404; a query should always return a list, even if it's empty.
330 return ReadHttpJsonResponse(CreateHttpConn(host, path), ignore_404=False)
331
332
deymo@chromium.orgf8be2762013-11-06 01:01:59 +0000333def GenerateAllChanges(host, param_dict, first_param=None, limit=500,
334 o_params=None, sortkey=None):
335 """
336 Queries a gerrit-on-borg server for all the changes matching the query terms.
337
338 A single query to gerrit-on-borg is limited on the number of results by the
339 limit parameter on the request (see QueryChanges) and the server maximum
340 limit. This function uses the "_more_changes" and "_sortkey" attributes on
341 the returned changes to iterate all of them making multiple queries to the
342 server, regardless the query limit.
343
344 Args:
345 param_dict, first_param: Refer to QueryChanges().
346 limit: Maximum number of requested changes per query.
347 o_params: Refer to QueryChanges().
348 sortkey: The value of the "_sortkey" attribute where starts from. None to
349 start from the first change.
350
351 Returns:
352 A generator object to the list of returned changes, possibly unbound.
353 """
354 more_changes = True
355 while more_changes:
356 page = QueryChanges(host, param_dict, first_param, limit, o_params, sortkey)
357 for cl in page:
358 yield cl
359
360 more_changes = [cl for cl in page if '_more_changes' in cl]
361 if len(more_changes) > 1:
362 raise GerritError(
363 200,
364 'Received %d changes with a _more_changes attribute set but should '
365 'receive at most one.' % len(more_changes))
366 if more_changes:
367 sortkey = more_changes[0]['_sortkey']
368
369
szager@chromium.orgb4696232013-10-16 19:45:35 +0000370def MultiQueryChanges(host, param_dict, change_list, limit=None, o_params=None,
371 sortkey=None):
372 """Initiate a query composed of multiple sets of query parameters."""
373 if not change_list:
374 raise RuntimeError(
375 "MultiQueryChanges requires a list of change numbers/id's")
376 q = ['q=%s' % '+OR+'.join([urllib.quote(str(x)) for x in change_list])]
377 if param_dict:
378 q.append(_QueryString(param_dict))
379 if limit:
380 q.append('n=%d' % limit)
381 if sortkey:
382 q.append('N=%s' % sortkey)
383 if o_params:
384 q.extend(['o=%s' % p for p in o_params])
385 path = 'changes/?%s' % '&'.join(q)
386 try:
387 result = ReadHttpJsonResponse(CreateHttpConn(host, path), ignore_404=False)
388 except GerritError as e:
389 msg = '%s:\n%s' % (e.message, path)
390 raise GerritError(e.http_status, msg)
391 return result
392
393
394def GetGerritFetchUrl(host):
395 """Given a gerrit host name returns URL of a gerrit instance to fetch from."""
396 return '%s://%s/' % (GERRIT_PROTOCOL, host)
397
398
399def GetChangePageUrl(host, change_number):
400 """Given a gerrit host name and change number, return change page url."""
401 return '%s://%s/#/c/%d/' % (GERRIT_PROTOCOL, host, change_number)
402
403
404def GetChangeUrl(host, change):
405 """Given a gerrit host name and change id, return an url for the change."""
406 return '%s://%s/a/changes/%s' % (GERRIT_PROTOCOL, host, change)
407
408
409def GetChange(host, change):
410 """Query a gerrit server for information about a single change."""
411 path = 'changes/%s' % change
412 return ReadHttpJsonResponse(CreateHttpConn(host, path))
413
414
415def GetChangeDetail(host, change, o_params=None):
416 """Query a gerrit server for extended information about a single change."""
417 path = 'changes/%s/detail' % change
418 if o_params:
419 path += '?%s' % '&'.join(['o=%s' % p for p in o_params])
420 return ReadHttpJsonResponse(CreateHttpConn(host, path))
421
422
423def GetChangeCurrentRevision(host, change):
424 """Get information about the latest revision for a given change."""
425 return QueryChanges(host, {}, change, o_params=('CURRENT_REVISION',))
426
427
428def GetChangeRevisions(host, change):
429 """Get information about all revisions associated with a change."""
430 return QueryChanges(host, {}, change, o_params=('ALL_REVISIONS',))
431
432
433def GetChangeReview(host, change, revision=None):
434 """Get the current review information for a change."""
435 if not revision:
436 jmsg = GetChangeRevisions(host, change)
437 if not jmsg:
438 return None
439 elif len(jmsg) > 1:
440 raise GerritError(200, 'Multiple changes found for ChangeId %s.' % change)
441 revision = jmsg[0]['current_revision']
442 path = 'changes/%s/revisions/%s/review'
443 return ReadHttpJsonResponse(CreateHttpConn(host, path))
444
445
446def AbandonChange(host, change, msg=''):
447 """Abandon a gerrit change."""
448 path = 'changes/%s/abandon' % change
449 body = {'message': msg} if msg else None
450 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
451 return ReadHttpJsonResponse(conn, ignore_404=False)
452
453
454def RestoreChange(host, change, msg=''):
455 """Restore a previously abandoned change."""
456 path = 'changes/%s/restore' % change
457 body = {'message': msg} if msg else None
458 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
459 return ReadHttpJsonResponse(conn, ignore_404=False)
460
461
462def SubmitChange(host, change, wait_for_merge=True):
463 """Submits a gerrit change via Gerrit."""
464 path = 'changes/%s/submit' % change
465 body = {'wait_for_merge': wait_for_merge}
466 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
467 return ReadHttpJsonResponse(conn, ignore_404=False)
468
469
470def GetReviewers(host, change):
471 """Get information about all reviewers attached to a change."""
472 path = 'changes/%s/reviewers' % change
473 return ReadHttpJsonResponse(CreateHttpConn(host, path))
474
475
476def GetReview(host, change, revision):
477 """Get review information about a specific revision of a change."""
478 path = 'changes/%s/revisions/%s/review' % (change, revision)
479 return ReadHttpJsonResponse(CreateHttpConn(host, path))
480
481
482def AddReviewers(host, change, add=None):
483 """Add reviewers to a change."""
484 if not add:
485 return
486 if isinstance(add, basestring):
487 add = (add,)
488 path = 'changes/%s/reviewers' % change
489 for r in add:
490 body = {'reviewer': r}
491 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
492 jmsg = ReadHttpJsonResponse(conn, ignore_404=False)
493 return jmsg
494
495
496def RemoveReviewers(host, change, remove=None):
497 """Remove reveiewers from a change."""
498 if not remove:
499 return
500 if isinstance(remove, basestring):
501 remove = (remove,)
502 for r in remove:
503 path = 'changes/%s/reviewers/%s' % (change, r)
504 conn = CreateHttpConn(host, path, reqtype='DELETE')
505 try:
506 ReadHttpResponse(conn, ignore_404=False)
507 except GerritError as e:
508 # On success, gerrit returns status 204; anything else is an error.
509 if e.http_status != 204:
510 raise
511 else:
512 raise GerritError(
513 'Unexpectedly received a 200 http status while deleting reviewer "%s"'
514 ' from change %s' % (r, change))
515
516
517def SetReview(host, change, msg=None, labels=None, notify=None):
518 """Set labels and/or add a message to a code review."""
519 if not msg and not labels:
520 return
521 path = 'changes/%s/revisions/current/review' % change
522 body = {}
523 if msg:
524 body['message'] = msg
525 if labels:
526 body['labels'] = labels
527 if notify:
528 body['notify'] = notify
529 conn = CreateHttpConn(host, path, reqtype='POST', body=body)
530 response = ReadHttpJsonResponse(conn)
531 if labels:
532 for key, val in labels.iteritems():
533 if ('labels' not in response or key not in response['labels'] or
534 int(response['labels'][key] != int(val))):
535 raise GerritError(200, 'Unable to set "%s" label on change %s.' % (
536 key, change))
537
538
539def ResetReviewLabels(host, change, label, value='0', message=None,
540 notify=None):
541 """Reset the value of a given label for all reviewers on a change."""
542 # This is tricky, because we want to work on the "current revision", but
543 # there's always the risk that "current revision" will change in between
544 # API calls. So, we check "current revision" at the beginning and end; if
545 # it has changed, raise an exception.
546 jmsg = GetChangeCurrentRevision(host, change)
547 if not jmsg:
548 raise GerritError(
549 200, 'Could not get review information for change "%s"' % change)
550 value = str(value)
551 revision = jmsg[0]['current_revision']
552 path = 'changes/%s/revisions/%s/review' % (change, revision)
553 message = message or (
554 '%s label set to %s programmatically.' % (label, value))
555 jmsg = GetReview(host, change, revision)
556 if not jmsg:
557 raise GerritError(200, 'Could not get review information for revison %s '
558 'of change %s' % (revision, change))
559 for review in jmsg.get('labels', {}).get(label, {}).get('all', []):
560 if str(review.get('value', value)) != value:
561 body = {
562 'message': message,
563 'labels': {label: value},
564 'on_behalf_of': review['_account_id'],
565 }
566 if notify:
567 body['notify'] = notify
568 conn = CreateHttpConn(
569 host, path, reqtype='POST', body=body)
570 response = ReadHttpJsonResponse(conn)
571 if str(response['labels'][label]) != value:
572 username = review.get('email', jmsg.get('name', ''))
573 raise GerritError(200, 'Unable to set %s label for user "%s"'
574 ' on change %s.' % (label, username, change))
575 jmsg = GetChangeCurrentRevision(host, change)
576 if not jmsg:
577 raise GerritError(
578 200, 'Could not get review information for change "%s"' % change)
579 elif jmsg[0]['current_revision'] != revision:
580 raise GerritError(200, 'While resetting labels on change "%s", '
581 'a new patchset was uploaded.' % change)