autoland: support both user/pass and user/api-key auth (bug 1332154) r?smacleod draft
authorbyron jones <glob@mozilla.com>
Wed, 01 Feb 2017 21:45:14 +0800
changeset 10440 66ebe92cec38ef74c4a22ea20ced46fc2addb0b1
parent 10437 6a5b4c985ffc368f9cef1d96c956fb3dd1120fc2
push id1543
push userbjones@mozilla.com
push dateThu, 23 Feb 2017 13:59:00 +0000
reviewerssmacleod
bugs1332154
autoland: support both user/pass and user/api-key auth (bug 1332154) r?smacleod Adds a class to manage authentication with Bugzilla. Supports both user/password and user/api-key. A lot of the complexity here stems from the pingback url being per-request rather than a global setting; this means we need to track session cookies per-site. MozReview-Commit-ID: 6MrYZpuGua9
autoland/autoland/autoland.py
autoland/autoland/mozreview.py
--- a/autoland/autoland/autoland.py
+++ b/autoland/autoland/autoland.py
@@ -218,31 +218,31 @@ def handle_pending_mozreview_updates(log
     query = """
         select MozreviewUpdate.id,transplant_id,request,data
         from MozreviewUpdate inner join Transplant
         on (Transplant.id = MozreviewUpdate.transplant_id)
         limit %(limit)s
     """
     cursor.execute(query, {'limit': MOZREVIEW_COMMENT_LIMIT})
 
-    mozreview_auth = mozreview.read_credentials()
+    bugzilla_auth = mozreview.instantiate_authentication()
 
     updated = []
     all_posted = True
     for row in cursor.fetchall():
         update_id, transplant_id, request, data = row
         pingback_url = request.get('pingback_url')
 
         logger.info('trying to post mozreview update to: %s for request: %s' %
                     (pingback_url, transplant_id))
 
         # We allow empty pingback_urls as they make testing easier. We can
         # always check the logs for misconfigured pingback_urls.
         if pingback_url:
-            status_code, text = mozreview.update_review(mozreview_auth,
+            status_code, text = mozreview.update_review(bugzilla_auth,
                                                         pingback_url, data)
             if status_code == 200:
                 updated.append([update_id])
             else:
                 logger.info('failed: %s - %s' % (status_code, text))
                 all_posted = False
                 break
         else:
--- a/autoland/autoland/mozreview.py
+++ b/autoland/autoland/mozreview.py
@@ -1,17 +1,103 @@
 import config
-import json
 import requests
+import urlparse
+
+API_KEY_LOGIN_PATH = ('/api/extensions/mozreview.extension.MozReviewExtension/'
+                      'bugzilla-api-key-logins/')
+
+# Requires a 'bugzilla' object in config.json.
+#
+# For user/api-key authentication (preferred):
+#   "bugzilla": {
+#       "user": "level1@example.com",
+#       "api-key": "znqzPYGqAoWrMbm88bmTbhg6KQUV4SdtW8T9VucX"
+#   }
+#
+# For user/password authentication:
+#   "bugzilla": {
+#       "user": "level1@example.com",
+#       "passwd": "password",
+#   }
+
+class BugzillaAuthException(Exception):
+    pass
 
 
-def read_credentials():
-    return config.get('bugzilla')['user'], config.get('bugzilla')['passwd']
+class BugzillaAuth(object):
+    """Base class for authentication."""
+
+    _config = None
+
+    def __init__(self, bugzilla_config):
+        self._config = bugzilla_config
+
+    def headers(self, pingback_url):
+        """HTTP headers to include in the pingback post."""
+        return {'Content-Type': 'application/json'}
+
+    def http_auth(self):
+        """Basic HTTP auth credentials, as user/pass tuple."""
+        return None
+
+
+class BugzillaAuthPassword(BugzillaAuth):
+    """Username/password authentication.  Used in dev and test."""
+
+    def http_auth(self):
+        return self._config['user'], self._config['passwd']
 
 
-def update_review(auth, pingback_url, data):
+class BugzillaAuthApiKey(BugzillaAuth):
+    """Username/API-Key authentication."""
+
+    _cookies = {}
+
+    def headers(self, pingback_url):
+        """Track cookies for each Review Board instance."""
+        url = urlparse.urlparse(pingback_url)
+        host = url.netloc
+
+        if host not in self._cookies:
+            # Authenticate using api-key to get session cookie. This cannot
+            # happen when the object is created, as requests may issue
+            # pingbacks to different urls.
+            url_parts = (url.scheme, url.netloc, API_KEY_LOGIN_PATH, '', '')
+            data = {
+                'username': self._config['user'],
+                'api_key': self._config['api-key'],
+            }
+            res = requests.post(urlparse.urlunsplit(url_parts), data=data)
+            if res.status_code != 201:
+                raise BugzillaAuthException('API-Key authentication failed')
+            self._cookies[host] = 'rbsessionid=%s' % res.cookies['rbsessionid']
+
+        headers = super(BugzillaAuthApiKey, self).headers(pingback_url)
+        headers['Cookie'] = self._cookies[host]
+        return headers
+
+
+def instantiate_authentication():
+    """Return the appropriate BugzillaAuth object."""
+    bugzilla_config = config.get('bugzilla')
+    if 'api-key' in bugzilla_config:
+        return BugzillaAuthApiKey(bugzilla_config)
+    else:
+        return BugzillaAuthPassword(bugzilla_config)
+
+
+def update_review(bugzilla_auth, pingback_url, data):
+    """Sends the 'data' to the 'pingback_url', handing auth and errors"""
     try:
-        r = requests.post(pingback_url, data=data,
-                          headers={'Content-Type': 'application/json'},
-                          auth=auth)
-        return r.status_code, r.text
-    except requests.exceptions.ConnectionError:
-        return None, 'could not connect'
+        res = requests.post(pingback_url,
+                            data=data,
+                            headers=bugzilla_auth.headers(pingback_url),
+                            auth=bugzilla_auth.http_auth())
+        if res.status_code == 401:
+            raise BugzillaAuthException('Login failure')
+        return res.status_code, res.text
+    except BugzillaAuthException as e:
+        return None, 'Failed to connect authenticate with MozReview: %s' % e
+    except requests.exceptions.ConnectionError as e:
+        return None, 'Failed to connect to MozReview: %s' % e
+    except requests.exceptions.RequestException as e:
+        return None, 'Failed to update MozReview: %s' % e