]> git.apps.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: Implement RGW proxy
authorPatrick Nawracay <pnawracay@suse.com>
Tue, 20 Mar 2018 08:03:30 +0000 (09:03 +0100)
committerVolker Theile <vtheile@suse.com>
Wed, 11 Apr 2018 10:26:42 +0000 (12:26 +0200)
This implementation is basically a Rados Gateway reverse proxy.  It
additionally takes care of the authentication to the Rados Gateway, but to use
it you will have to be authenticated against the dashboards RESTful API.

The corresponding credentials can be configured using the following commands:

    dashboard set-rgw-api-secret-key <secret-key>

    dashboard set-rgw-api-access-key <access-key>

Signed-off-by: Patrick Nawracay <pnawracay@suse.com>
12 files changed:
qa/tasks/mgr/dashboard/helper.py
qa/tasks/mgr/dashboard/test_rgw.py
src/pybind/mgr/dashboard/README.rst
src/pybind/mgr/dashboard/awsauth.py
src/pybind/mgr/dashboard/controllers/rgw.py
src/pybind/mgr/dashboard/exceptions.py [new file with mode: 0644]
src/pybind/mgr/dashboard/rest_client.py [new file with mode: 0644]
src/pybind/mgr/dashboard/services/rgw_client.py [new file with mode: 0644]
src/pybind/mgr/dashboard/settings.py
src/pybind/mgr/dashboard/tests/test_tools.py
src/pybind/mgr/dashboard/tools.py
src/pybind/mgr/dashboard/tox.ini

index f367a3ac5c4c8b49ab87d57eb709beea8c5e03b2..6f93d42705dcbf9993b9d378b49f7370bd5dbafb 100644 (file)
@@ -76,36 +76,38 @@ class DashboardTestCase(MgrTestCase):
         self._session = requests.Session()
         self._resp = None
 
-    def _request(self, url, method, data=None):
+    def _request(self, url, method, data=None, params=None):
         url = "{}{}".format(self.base_uri, url)
+
         log.info("request %s to %s", method, url)
         if method == 'GET':
-            self._resp = self._session.get(url)
+            self._resp = self._session.get(url, params=params)
             try:
                 return self._resp.json()
             except ValueError as ex:
                 log.exception("Failed to decode response: %s", self._resp.text)
                 raise ex
         elif method == 'POST':
-            self._resp = self._session.post(url, json=data)
+            self._resp = self._session.post(url, json=data, params=params)
         elif method == 'DELETE':
-            self._resp = self._session.delete(url, json=data)
+            self._resp = self._session.delete(url, json=data, params=params)
         elif method == 'PUT':
-            self._resp = self._session.put(url, json=data)
+            self._resp = self._session.put(url, json=data, params=params)
         else:
             assert False
+        return None
 
-    def _get(self, url):
-        return self._request(url, 'GET')
+    def _get(self, url, params=None):
+        return self._request(url, 'GET', params=params)
 
-    def _post(self, url, data=None):
-        self._request(url, 'POST', data)
+    def _post(self, url, data=None, params=None):
+        self._request(url, 'POST', data, params=params)
 
-    def _delete(self, url, data=None):
-        self._request(url, 'DELETE', data)
+    def _delete(self, url, data=None, params=None):
+        self._request(url, 'DELETE', data, params=params)
 
-    def _put(self, url, data=None):
-        self._request(url, 'PUT', data)
+    def _put(self, url, data=None, params=None):
+        self._request(url, 'PUT', data, params=params)
 
     def cookies(self):
         return self._resp.cookies
@@ -152,6 +154,14 @@ class DashboardTestCase(MgrTestCase):
         args.extend(cmd)
         cls.mgr_cluster.admin_remote.run(args=args)
 
+    @classmethod
+    def _radosgw_admin_cmd(cls, cmd):
+        args = [
+            'radosgw-admin'
+        ]
+        args.extend(cmd)
+        cls.mgr_cluster.admin_remote.run(args=args)
+
     @classmethod
     def mons(cls):
         out = cls.ceph_cluster.mon_manager.raw_cluster_cmd('mon_status')
index f6cbf843c624e420e264ce90e5000f790dd4102e..4e780fabfb37b8a97d7a0051c2c27c287f5f9a16 100644 (file)
@@ -1,11 +1,13 @@
 # -*- coding: utf-8 -*-
 from __future__ import absolute_import
+import urllib
+import logging
+logger = logging.getLogger(__name__)
 
 from .helper import DashboardTestCase, authenticate
 
 
 class RgwControllerTest(DashboardTestCase):
-
     @authenticate
     def test_rgw_daemon_list(self):
         data = self._get('/api/rgw/daemon')
@@ -28,3 +30,111 @@ class RgwControllerTest(DashboardTestCase):
         self.assertIn('rgw_id', data)
         self.assertIn('rgw_status', data)
         self.assertTrue(data['rgw_metadata'])
+
+class RgwProxyExceptionsTest(DashboardTestCase):
+
+    @classmethod
+    def setUpClass(cls):
+        super(RgwProxyExceptionsTest, cls).setUpClass()
+
+        cls._ceph_cmd(['dashboard', 'set-rgw-api-secret-key', ''])
+        cls._ceph_cmd(['dashboard', 'set-rgw-api-access-key', ''])
+
+    @authenticate
+    def test_no_credentials_exception(self):
+        resp = self._get('/api/rgw/proxy/status')
+        self.assertStatus(401)
+        self.assertIn('message', resp)
+
+
+class RgwProxyTest(DashboardTestCase):
+    @classmethod
+    def setUpClass(cls):
+        super(RgwProxyTest, cls).setUpClass()
+        cls._radosgw_admin_cmd([
+            'user', 'create', '--uid=admin', '--display-name=admin',
+            '--system', '--access-key=admin', '--secret=admin'
+        ])
+        cls._ceph_cmd(['dashboard', 'set-rgw-api-secret-key', 'admin'])
+        cls._ceph_cmd(['dashboard', 'set-rgw-api-access-key', 'admin'])
+
+    def _assert_user_data(self, data):
+        self.assertIn('caps', data)
+        self.assertIn('display_name', data)
+        self.assertIn('email', data)
+        self.assertIn('keys', data)
+        self.assertGreaterEqual(len(data['keys']), 1)
+        self.assertIn('max_buckets', data)
+        self.assertIn('subusers', data)
+        self.assertIn('suspended', data)
+        self.assertIn('swift_keys', data)
+        self.assertIn('tenant', data)
+        self.assertIn('user_id', data)
+
+    def _test_put(self):
+        self._put(
+            '/api/rgw/proxy/user',
+            params={
+                'uid': 'teuth-test-user',
+                'display-name': 'display name',
+            })
+        data = self._resp.json()
+
+        self._assert_user_data(data)
+        self.assertStatus(200)
+
+        data = self._get(
+            '/api/rgw/proxy/user', params={'uid': 'teuth-test-user'})
+
+        self.assertStatus(200)
+        self.assertEqual(data['user_id'], 'teuth-test-user')
+
+    def _test_get(self):
+        data = self._get(
+            '/api/rgw/proxy/user', params={'uid': 'teuth-test-user'})
+
+        self._assert_user_data(data)
+        self.assertStatus(200)
+        self.assertEquals(data['user_id'], 'teuth-test-user')
+
+    def _test_post(self):
+        """Updates the user"""
+        self._post(
+            '/api/rgw/proxy/user',
+            params={
+                'uid': 'teuth-test-user',
+                'display-name': 'new name'
+            })
+
+        self.assertStatus(200)
+        self._assert_user_data(self._resp.json())
+        self.assertEqual(self._resp.json()['display_name'], 'new name')
+
+    def _test_delete(self):
+        self._delete('/api/rgw/proxy/user', params={'uid': 'teuth-test-user'})
+        self.assertStatus(200)
+
+        self._delete('/api/rgw/proxy/user', params={'uid': 'teuth-test-user'})
+        self.assertStatus(404)
+        resp = self._resp.json()
+        self.assertIn('Code', resp)
+        self.assertIn('HostId', resp)
+        self.assertIn('RequestId', resp)
+        self.assertEqual(resp['Code'], 'NoSuchUser')
+
+    @authenticate
+    def test_rgw_proxy(self):
+        """Test basic request types"""
+        self.maxDiff = None
+
+        # PUT - Create a user
+        self._test_put()
+
+        # GET - Get the user details
+        self._test_get()
+
+        # POST - Update the user details
+        self._test_post()
+
+        # DELETE - Delete the user
+        self._test_delete()
index 22368188138bc7f32c7812c69436958695597708..27c586005f007562627d2423ba8b3ce179453dc5 100644 (file)
@@ -69,6 +69,42 @@ The password will be stored as a hash using ``bcrypt``.
 
 The Dashboard's WebUI should then be reachable on TCP port 8080.
 
+Enabling the Object Gateway management frontend
+-----------------------------------------------
+
+If you want to use the Object Gateway management functionality of the
+dashboard, you will need to provide credentials. If you do not have a user
+which shall be used for providing those credentials, you will also need to
+create one::
+
+  $ radosgw-admin user create --uuid=<user> --display-name=<display-name> \
+      --system
+
+The credentials of a user can also be obtained by using `radosgw-admin`::
+
+  $ radosgw-admin user info --uid=<user>
+
+Finally, set the credentials to the dashboard module::
+
+  $ ceph dashboard set-rgw-api-secret-key <secret_key>
+  $ ceph dashboard set-rgw-api-access-key <access_key>
+
+This is all you have to do to get the Object Gateway management functionality
+working. The host and port of the Object Gateway are determined automatically.
+If multiple zones are used, it will automatically determine the host within the
+master zone group and master zone. This should be sufficient for most setups,
+but in some circumstances you might want to set the host and port manually::
+
+  $ ceph dashboard set-rgw-api-host <host>
+  $ ceph dashboard set-rgw-api-port <port>
+
+In addition to the settings mentioned so far, the following settings do also
+exist and you may find yourself in the situation that you have to use them::
+
+  $ ceph dashboard set-rgw-api-scheme <scheme>  # http or https
+  $ ceph dashboard set-rgw-api-admin-resource <admin-resource>
+  $ ceph dashboard set-rgw-api-user-id <user-id>
+
 Working on the Dashboard Code
 -----------------------------
 
index 47b1e0b8ccb7e6d9292ed8ac64c3771bae157550..123d6282519b9d5d53f4bd4b38bdb4bb77ead9da 100644 (file)
@@ -1,8 +1,5 @@
 # -*- coding: utf-8 -*-
-<<<<<<< HEAD
 # pylint: disable-all
-=======
->>>>>>> 60417c2dcc... dashboard/mgr: RGW proxy: Include `python-requests-aws`
 #
 # Copyright (c) 2012-2013 Paul Tax <paultax@gmail.com> All rights reserved.
 #
index d0e60aa5946c29cb70d74bc905ddf2f6060db94e..ad4f4c2cc1c074bcacba058bd4e0ee0fd2752aec 100644 (file)
@@ -3,9 +3,14 @@ from __future__ import absolute_import
 
 import json
 
-from . import ApiController, RESTController, AuthRequired
+import cherrypy
+
 from .. import logger
 from ..services.ceph_service import CephService
+from ..tools import ApiController, RESTController, AuthRequired
+from ..services.rgw_client import RgwClient
+from ..rest_client import RequestException
+from ..exceptions import NoCredentialsException
 
 
 @ApiController('rgw')
@@ -17,12 +22,20 @@ class Rgw(RESTController):
 @ApiController('rgw/daemon')
 @AuthRequired()
 class RgwDaemon(RESTController):
-
     def list(self):
         daemons = []
         for hostname, server in CephService.get_service_map('rgw').items():
             for service in server['services']:
                 metadata = service['metadata']
+                status = service['status']
+                if 'json' in status:
+                    try:
+                        status = json.loads(status['json'])
+                    except ValueError:
+                        logger.warning("%s had invalid status json", service['id'])
+                        status = {}
+                else:
+                    logger.warning('%s has no key "json" in status', service['id'])
 
                 # extract per-daemon service data and health
                 daemon = {
@@ -58,4 +71,32 @@ class RgwDaemon(RESTController):
 
         daemon['rgw_metadata'] = metadata
         daemon['rgw_status'] = status
+
         return daemon
+
+
+@ApiController('rgw/proxy')
+@AuthRequired()
+class RgwProxy(RESTController):
+    @cherrypy.expose
+    def default(self, *vpath, **params):
+        try:
+            rgw_client = RgwClient.admin_instance()
+
+        except NoCredentialsException as e:
+            cherrypy.response.headers['Content-Type'] = 'application/json'
+            cherrypy.response.status = 401
+            return json.dumps({'message': e.message})
+
+        method = cherrypy.request.method
+        path = '/'.join(vpath)
+        data = None
+
+        if cherrypy.request.body.length:
+            data = cherrypy.request.body.read()
+
+        try:
+            return rgw_client.proxy(method, path, params, data)
+        except RequestException as e:
+            cherrypy.response.status = e.status_code
+            return e.content
diff --git a/src/pybind/mgr/dashboard/exceptions.py b/src/pybind/mgr/dashboard/exceptions.py
new file mode 100644 (file)
index 0000000..8e24095
--- /dev/null
@@ -0,0 +1,10 @@
+# -*- coding: utf-8 -*-
+from __future__ import absolute_import
+
+
+class NoCredentialsException(Exception):
+    def __init__(self):
+        super(Exception, self).__init__(
+            'No RGW credentials found, '
+            'please consult the documentation on how to enable RGW for '
+            'the dashboard.')
diff --git a/src/pybind/mgr/dashboard/rest_client.py b/src/pybind/mgr/dashboard/rest_client.py
new file mode 100644 (file)
index 0000000..cf339ef
--- /dev/null
@@ -0,0 +1,509 @@
+# -*- coding: utf-8 -*-
+"""
+ *   Copyright (c) 2017 SUSE LLC
+ *
+ *  openATTIC is free software; you can redistribute it and/or modify it
+ *  under the terms of the GNU General Public License as published by
+ *  the Free Software Foundation; version 2.
+ *
+ *  This package is distributed in the hope that it will be useful,
+ *  but WITHOUT ANY WARRANTY; without even the implied warranty of
+ *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ *  GNU General Public License for more details.
+"""
+from __future__ import absolute_import
+
+from .tools import build_url
+import inspect
+import itertools
+import re
+import requests
+from requests.exceptions import ConnectionError, InvalidURL
+from . import logger
+
+try:
+    from requests.packages.urllib3.exceptions import SSLError
+except ImportError:
+    from urllib3.exceptions import SSLError
+
+
+class RequestException(Exception):
+    def __init__(self,
+                 message,
+                 status_code=None,
+                 content=None,
+                 conn_errno=None,
+                 conn_strerror=None):
+        super(RequestException, self).__init__(message)
+        self.status_code = status_code
+        self.content = content
+        self.conn_errno = conn_errno
+        self.conn_strerror = conn_strerror
+
+
+class BadResponseFormatException(RequestException):
+    def __init__(self, message):
+        super(BadResponseFormatException, self).__init__(
+            "Bad response format" if message is None else message, None)
+
+
+class _ResponseValidator(object):
+    """Simple JSON schema validator
+
+    This class implements a very simple validator for the JSON formatted
+    messages received by request responses from a RestClient instance.
+
+    The validator validates the JSON response against a "structure" string that
+    specifies the structure that the JSON response must comply.  The validation
+    procedure raises a BadResponseFormatException in case of a validation
+    failure.
+
+    The structure syntax is given by the following grammar:
+
+    Structure  ::=  Level
+    Level      ::=  Path | Path '&' Level
+    Path       ::=  Step | Step '>'+ Path
+    Step       ::=  Key  | '?' Key | '*' | '(' Level ')'
+    Key        ::=  <string> | Array+
+    Array      ::=  '[' <int> ']' | '[' '*' ']' | '[' '+' ']'
+
+    The symbols enclosed in ' ' are tokens of the language, and the + symbol
+    denotes repetition of of the preceding token at least once.
+
+    Examples of usage:
+
+    Example 1:
+        Validator args:
+            structure = "return > *"
+            response = { 'return': { ... } }
+
+        In the above example the structure will validate against any response
+        that contains a key named "return" in the root of the response
+        dictionary and its value is also a dictionary.
+
+    Example 2:
+        Validator args:
+            structure = "[*]"
+            response = [...]
+
+        In the above example the structure will validate against any response
+        that is an array of any size.
+
+    Example 3:
+        Validator args:
+            structure = "return[*]"
+            response = { 'return': [....] }
+
+        In the above example the structure will validate against any response
+        that contains a key named "return" in the root of the response
+        dictionary and its value is an array.
+
+    Example 4:
+        Validator args:
+            structure = "return[0] > token"
+            response = { 'return': [ { 'token': .... } ] }
+
+        In the above example the structure will validate against any response
+        that contains a key named "return" in the root of the response
+        dictionary and its value is an array, and the first element of the
+        array is a dictionary that contains the key 'token'.
+
+    Example 5:
+        Validator args:
+            structure = "return[0][*] > key1"
+            response = { 'return': [ [ { 'key1': ... } ], ...] }
+
+        In the above example the structure will validate against any response
+        that contains a key named "return" in the root of the response
+        dictionary where its value is an array, and the first value of this
+        array is also an array where all it's values must be a dictionary
+        containing a key named "key1".
+
+    Example 6:
+        Validator args:
+            structure = "return > (key1[*] & key2 & ?key3 > subkey)"
+            response = { 'return': { 'key1': [...], 'key2: .... } ] }
+
+        In the above example the structure will validate against any response
+        that contains a key named "return" in the root of the response
+        dictionary and its value is a dictionary that must contain a key named
+        "key1" that is an array, a key named "key2", and optionaly a key named
+        "key3" that is a dictionary that contains a key named "subkey".
+
+    Example 7:
+        Validator args:
+            structure = "return >> roles[*]"
+            response = { 'return': { 'key1': { 'roles': [...] }, 'key2': { 'roles': [...] } } }
+
+        In the above example the structure will validate against any response
+        that contains a key named "return" in the root of the response
+        dictionary, and its value is a dictionary that for any key present in
+        the dictionary their value is also a dictionary that must contain a key
+        named 'roles' that is an array.  Please note that you can use any
+        number of successive '>' to denote the level in the JSON tree that you
+        want to match next step in the path.
+
+    """
+
+    @staticmethod
+    def validate(structure, response):
+        if structure is None:
+            return
+
+        _ResponseValidator._validate_level(structure, response)
+
+    @staticmethod
+    def _validate_level(level, resp):
+        if not isinstance(resp, dict) and not isinstance(resp, list):
+            raise BadResponseFormatException(
+                "{} is neither a dict nor a list".format(resp))
+
+        paths = _ResponseValidator._parse_level_paths(level)
+        for path in paths:
+            path_sep = path.find('>')
+            if path_sep != -1:
+                level_next = path[path_sep + 1:].strip()
+            else:
+                path_sep = len(path)
+                level_next = None
+            key = path[:path_sep].strip()
+
+            if key == '*':
+                continue
+            elif key == '':  # check all keys
+                for k in resp.keys():
+                    _ResponseValidator._validate_key(k, level_next, resp)
+            else:
+                _ResponseValidator._validate_key(key, level_next, resp)
+
+    @staticmethod
+    def _validate_array(array_seq, level_next, resp):
+        if array_seq:
+            if not isinstance(resp, list):
+                raise BadResponseFormatException(
+                    "{} is not an array".format(resp))
+            if array_seq[0].isdigit():
+                idx = int(array_seq[0])
+                if len(resp) <= idx:
+                    raise BadResponseFormatException(
+                        "length of array {} is lower than the index {}".format(
+                            resp, idx))
+                _ResponseValidator._validate_array(array_seq[1:], level_next,
+                                                   resp[idx])
+            elif array_seq[0] == '*':
+                for r in resp:
+                    _ResponseValidator._validate_array(array_seq[1:],
+                                                       level_next, r)
+            elif array_seq[0] == '+':
+                if len(resp) < 1:
+                    raise BadResponseFormatException(
+                        "array should not be empty")
+                for r in resp:
+                    _ResponseValidator._validate_array(array_seq[1:],
+                                                       level_next, r)
+            else:
+                raise Exception(
+                    "Response structure is invalid: only <int> | '*' are "
+                    "allowed as array index arguments")
+        else:
+            if level_next:
+                _ResponseValidator._validate_level(level_next, resp)
+
+    @staticmethod
+    def _validate_key(key, level_next, resp):
+        array_access = [a.strip() for a in key.split("[")]
+        key = array_access[0]
+        if key:
+            optional = key[0] == '?'
+            if optional:
+                key = key[1:]
+            if key not in resp:
+                if optional:
+                    return
+                raise BadResponseFormatException(
+                    "key {} is not in dict {}".format(key, resp))
+            resp_next = resp[key]
+        else:
+            resp_next = resp
+        if len(array_access) > 1:
+            _ResponseValidator._validate_array(
+                [a[0:-1] for a in array_access[1:]], level_next, resp_next)
+        else:
+            if level_next:
+                _ResponseValidator._validate_level(level_next, resp_next)
+
+    @staticmethod
+    def _parse_level_paths(level):
+        level = level.strip()
+        if level[0] == '(':
+            level = level[1:]
+            if level[-1] == ')':
+                level = level[:-1]
+
+        paths = []
+        lp = 0
+        nested = 0
+        for i, c in enumerate(level):
+            if c == '&' and nested == 0:
+                paths.append(level[lp:i].strip())
+                lp = i + 1
+            elif c == '(':
+                nested += 1
+            elif c == ')':
+                nested -= 1
+        paths.append(level[lp:].strip())
+        return paths
+
+
+class _Request(object):
+    def __init__(self, method, path, path_params, rest_client, resp_structure):
+        self.method = method
+        self.path = path
+        self.path_params = path_params
+        self.rest_client = rest_client
+        self.resp_structure = resp_structure
+
+    def _gen_path(self):
+        new_path = self.path
+        matches = re.finditer(r'\{(\w+?)\}', self.path)
+        for match in matches:
+            if match:
+                param_key = match.group(1)
+                if param_key in self.path_params:
+                    new_path = new_path.replace(
+                        match.group(0), self.path_params[param_key])
+                else:
+                    raise RequestException(
+                        'Invalid path. Param "{}" was not specified'
+                        .format(param_key), None)
+        return new_path
+
+    def __call__(self,
+                 req_data=None,
+                 method=None,
+                 params=None,
+                 data=None,
+                 raw_content=False):
+        method = method if method else self.method
+        if not method:
+            raise Exception('No HTTP request method specified')
+        if req_data:
+            if method == 'get':
+                if params:
+                    raise Exception('Ambiguous source of GET params')
+                params = req_data
+            else:
+                if data:
+                    raise Exception('Ambiguous source of {} data'.format(
+                        method.upper()))
+                data = req_data
+        resp = self.rest_client.do_request(method, self._gen_path(), params,
+                                           data, raw_content)
+        if raw_content and self.resp_structure:
+            raise Exception("Cannot validate reponse in raw format")
+        _ResponseValidator.validate(self.resp_structure, resp)
+        return resp
+
+
+class RestClient(object):
+    def __init__(self, host, port, client_name=None, ssl=False, auth=None):
+        super(RestClient, self).__init__()
+        self.client_name = client_name if client_name else ''
+        self.host = host
+        self.port = port
+        self.base_url = build_url(
+            scheme='https' if ssl else 'http', host=host, port=port)
+        logger.debug("REST service base URL: %s", self.base_url)
+        self.headers = {'Accept': 'application/json'}
+        self.auth = auth
+        self.session = requests.Session()
+
+    def _login(self, request=None):
+        pass
+
+    def _is_logged_in(self):
+        pass
+
+    def _reset_login(self):
+        pass
+
+    def is_service_online(self, request=None):
+        pass
+
+    @staticmethod
+    def requires_login(func):
+        def func_wrapper(self, *args, **kwargs):
+            retries = 2
+            while True:
+                try:
+                    if not self._is_logged_in():
+                        self._login()
+                    resp = func(self, *args, **kwargs)
+                    return resp
+                except RequestException as e:
+                    if isinstance(e, BadResponseFormatException):
+                        raise e
+                    retries -= 1
+                    if e.status_code not in [401, 403] or retries == 0:
+                        raise e
+                    self._reset_login()
+
+        return func_wrapper
+
+    def do_request(self,
+                   method,
+                   path,
+                   params=None,
+                   data=None,
+                   raw_content=False):
+        url = '{}{}'.format(self.base_url, path)
+        logger.debug('%s REST API %s req: %s data: %s', self.client_name,
+                     method.upper(), path, data)
+        try:
+            if method.lower() == 'get':
+                resp = self.session.get(
+                    url, headers=self.headers, params=params, auth=self.auth)
+            elif method.lower() == 'post':
+                resp = self.session.post(
+                    url,
+                    headers=self.headers,
+                    params=params,
+                    data=data,
+                    auth=self.auth)
+            elif method.lower() == 'put':
+                resp = self.session.put(
+                    url,
+                    headers=self.headers,
+                    params=params,
+                    data=data,
+                    auth=self.auth)
+            elif method.lower() == 'delete':
+                resp = self.session.delete(
+                    url,
+                    headers=self.headers,
+                    params=params,
+                    data=data,
+                    auth=self.auth)
+            else:
+                raise RequestException('Method "{}" not supported'.format(
+                    method.upper()), None)
+            if resp.ok:
+                logger.debug("%s REST API %s res status: %s content: %s",
+                             self.client_name, method.upper(),
+                             resp.status_code, resp.text)
+                if raw_content:
+                    return resp.content
+                try:
+                    return resp.json() if resp.text else None
+                except ValueError:
+                    logger.error(
+                        "%s REST API failed %s req while decoding JSON "
+                        "response : %s",
+                        self.client_name, method.upper(), resp.text)
+                    raise RequestException(
+                        "{} REST API failed request while decoding JSON "
+                        "response: {}".format(self.client_name, resp.text),
+                        resp.status_code, resp.text)
+            else:
+                logger.error(
+                    "%s REST API failed %s req status: %s", self.client_name,
+                    method.upper(), resp.status_code)
+                from pprint import pprint as pp
+                from pprint import pformat as pf
+
+                raise RequestException(
+                    "{} REST API failed request with status code {}\n"
+                    "{}"  # TODO remove
+                    .format(self.client_name, resp.status_code, pf(
+                        resp.content)),
+                    resp.status_code,
+                    resp.content)
+        except ConnectionError as ex:
+            if ex.args:
+                if isinstance(ex.args[0], SSLError):
+                    errno = "n/a"
+                    strerror = "SSL error. Probably trying to access a non " \
+                               "SSL connection."
+                    logger.error("%s REST API failed %s, SSL error.",
+                                 self.client_name, method.upper())
+                else:
+                    match = re.match(r'.*: \[Errno (-?\d+)\] (.+)',
+                                     ex.args[0].reason.args[0])
+                    if match:
+                        errno = match.group(1)
+                        strerror = match.group(2)
+                        logger.error(
+                            "%s REST API failed %s, connection error: "
+                            "[errno: %s] %s",
+                            self.client_name, method.upper(), errno, strerror)
+                    else:
+                        errno = "n/a"
+                        strerror = "n/a"
+                        logger.error(
+                            "%s REST API failed %s, connection error.",
+                            self.client_name, method.upper())
+            else:
+                errno = "n/a"
+                strerror = "n/a"
+                logger.error("%s REST API failed %s, connection error.",
+                             self.client_name, method.upper())
+
+            if errno != "n/a":
+                ex_msg = (
+                    "{} REST API cannot be reached: {} [errno {}]. "
+                    "Please check your configuration and that the API endpoint"
+                    " is accessible"
+                    .format(self.client_name, strerror, errno))
+            else:
+                ex_msg = (
+                    "{} REST API cannot be reached. Please check "
+                    "your configuration and that the API endpoint is"
+                    " accessible"
+                    .format(self.client_name))
+            raise RequestException(
+                ex_msg, conn_errno=errno, conn_strerror=strerror)
+        except InvalidURL as ex:
+            logger.exception("%s REST API failed %s: %s", self.client_name,
+                             method.upper(), str(ex))
+            raise RequestException(str(ex))
+
+    @staticmethod
+    def api(path, **api_kwargs):
+        def call_decorator(func):
+            def func_wrapper(self, *args, **kwargs):
+                method = api_kwargs.get('method', None)
+                resp_structure = api_kwargs.get('resp_structure', None)
+                args_name = inspect.getargspec(func).args
+                args_dict = dict(itertools.izip(args_name[1:], args))
+                for key, val in kwargs:
+                    args_dict[key] = val
+                return func(
+                    self,
+                    *args,
+                    request=_Request(method, path, args_dict, self,
+                                     resp_structure),
+                    **kwargs)
+
+            return func_wrapper
+
+        return call_decorator
+
+    @staticmethod
+    def api_get(path, resp_structure=None):
+        return RestClient.api(
+            path, method='get', resp_structure=resp_structure)
+
+    @staticmethod
+    def api_post(path, resp_structure=None):
+        return RestClient.api(
+            path, method='post', resp_structure=resp_structure)
+
+    @staticmethod
+    def api_put(path, resp_structure=None):
+        return RestClient.api(
+            path, method='put', resp_structure=resp_structure)
+
+    @staticmethod
+    def api_delete(path, resp_structure=None):
+        return RestClient.api(
+            path, method='delete', resp_structure=resp_structure)
diff --git a/src/pybind/mgr/dashboard/services/rgw_client.py b/src/pybind/mgr/dashboard/services/rgw_client.py
new file mode 100644 (file)
index 0000000..405e0b0
--- /dev/null
@@ -0,0 +1,210 @@
+# -*- coding: utf-8 -*-
+from __future__ import absolute_import
+
+import re
+from ..awsauth import S3Auth
+from ..settings import Settings, Options
+from ..rest_client import RestClient, RequestException
+from ..tools import build_url, dict_contains_path
+from ..exceptions import NoCredentialsException
+from .. import mgr, logger
+
+
+def _determine_rgw_addr():
+    service_map = mgr.get('service_map')
+
+    if not dict_contains_path(service_map, ['services', 'rgw', 'daemons', 'rgw']):
+        msg = 'No RGW found.'
+        raise LookupError(msg)
+
+    daemon = service_map['services']['rgw']['daemons']['rgw']
+    addr = daemon['addr'].split(':')[0]
+    match = re.search(r'port=(\d+)',
+                      daemon['metadata']['frontend_config#0'])
+    if match:
+        port = int(match.group(1))
+    else:
+        msg = 'Failed to determine RGW port'
+        raise LookupError(msg)
+
+    return addr, port
+
+
+class RgwClient(RestClient):
+    _SYSTEM_USERID = None
+    _ADMIN_PATH = None
+    _host = None
+    _port = None
+    _ssl = None
+    _user_instances = {}
+
+    @staticmethod
+    def _load_settings():
+        if Settings.RGW_API_SCHEME and Settings.RGW_API_ACCESS_KEY and \
+                Settings.RGW_API_SECRET_KEY:
+            if Options.has_default_value('RGW_API_HOST') and \
+                    Options.has_default_value('RGW_API_PORT'):
+                host, port = _determine_rgw_addr()
+            else:
+                host, port = Settings.RGW_API_HOST, Settings.RGW_API_PORT
+        else:
+            logger.warning('No credentials found, please consult the '
+                           'documentation about how to enable RGW for the '
+                           'dashboard.')
+            raise NoCredentialsException()
+
+        RgwClient._host = host
+        RgwClient._port = port
+        RgwClient._ssl = Settings.RGW_API_SCHEME == 'https'
+        RgwClient._ADMIN_PATH = Settings.RGW_API_ADMIN_RESOURCE
+        RgwClient._SYSTEM_USERID = Settings.RGW_API_USER_ID
+
+        logger.info("Creating new connection for user: %s",
+                    Settings.RGW_API_USER_ID)
+        RgwClient._user_instances[RgwClient._SYSTEM_USERID] = \
+            RgwClient(Settings.RGW_API_USER_ID, Settings.RGW_API_ACCESS_KEY,
+                      Settings.RGW_API_SECRET_KEY)
+
+    @staticmethod
+    def instance(userid):
+        if not RgwClient._user_instances:
+            RgwClient._load_settings()
+        if not userid:
+            userid = RgwClient._SYSTEM_USERID
+        if userid not in RgwClient._user_instances:
+            logger.info("Creating new connection for user: %s", userid)
+            keys = RgwClient.admin_instance().get_user_keys(userid)
+            if not keys:
+                raise Exception(
+                    "User '{}' does not have any keys configured.".format(
+                        userid))
+
+            RgwClient._user_instances[userid] = RgwClient(
+                userid, keys['access_key'], keys['secret_key'])
+        return RgwClient._user_instances[userid]
+
+    @staticmethod
+    def admin_instance():
+        return RgwClient.instance(RgwClient._SYSTEM_USERID)
+
+    def _reset_login(self):
+        if self.userid != RgwClient._SYSTEM_USERID:
+            logger.info("Fetching new keys for user: %s", self.userid)
+            keys = RgwClient.admin_instance().get_user_keys(self.userid)
+            self.auth = S3Auth(keys['access_key'], keys['secret_key'],
+                               service_url=self.service_url)
+        else:
+            raise RequestException('Authentication failed for the "{}" user: wrong credentials'
+                                   .format(self.userid), status_code=401)
+
+    def __init__(self,  # pylint: disable-msg=R0913
+                 userid,
+                 access_key,
+                 secret_key,
+                 host=None,
+                 port=None,
+                 admin_path='admin',
+                 ssl=False):
+
+        if not host and not RgwClient._host:
+            RgwClient._load_settings()
+        host = host if host else RgwClient._host
+        port = port if port else RgwClient._port
+        admin_path = admin_path if admin_path else RgwClient._ADMIN_PATH
+        ssl = ssl if ssl else RgwClient._ssl
+
+        self.userid = userid
+        self.service_url = build_url(host=host, port=port)
+        self.admin_path = admin_path
+
+        s3auth = S3Auth(access_key, secret_key, service_url=self.service_url)
+        super(RgwClient, self).__init__(host, port, 'RGW', ssl, s3auth)
+
+        logger.info("Creating new connection")
+
+    @RestClient.api_get('/', resp_structure='[0] > ID')
+    def is_service_online(self, request=None):
+        response = request({'format': 'json'})
+        return response[0]['ID'] == 'online'
+
+    @RestClient.api_get('/{admin_path}/metadata/user', resp_structure='[+]')
+    def _is_system_user(self, admin_path, request=None):
+        # pylint: disable=unused-argument
+        response = request()
+        return self.userid in response
+
+    def is_system_user(self):
+        return self._is_system_user(self.admin_path)
+
+    @RestClient.api_get(
+        '/{admin_path}/user',
+        resp_structure='tenant & user_id & email & keys[*] > '
+        ' (user & access_key & secret_key)')
+    def _admin_get_user_keys(self, admin_path, userid, request=None):
+        # pylint: disable=unused-argument
+        colon_idx = userid.find(':')
+        user = userid if colon_idx == -1 else userid[:colon_idx]
+        response = request({'uid': user})
+        for keys in response['keys']:
+            if keys['user'] == userid:
+                return {
+                    'access_key': keys['access_key'],
+                    'secret_key': keys['secret_key']
+                }
+        return None
+
+    def get_user_keys(self, userid):
+        return self._admin_get_user_keys(self.admin_path, userid)
+
+    @RestClient.api('/{admin_path}/{path}')
+    def _proxy_request(self,  # pylint: disable=too-many-arguments
+                       admin_path,
+                       path,
+                       method,
+                       params,
+                       data,
+                       request=None):
+        # pylint: disable=unused-argument
+        return request(
+            method=method, params=params, data=data, raw_content=True)
+
+    def proxy(self, method, path, params, data):
+        logger.debug("proxying method=%s path=%s params=%s data=%s", method,
+                     path, params, data)
+        return self._proxy_request(self.admin_path, path, method, params, data)
+
+    @RestClient.api_get('/', resp_structure='[1][*] > Name')
+    def get_buckets(self, request=None):
+        """
+        Get a list of names from all existing buckets of this user.
+        :return: Returns a list of bucket names.
+        """
+        response = request({'format': 'json'})
+        return [bucket['Name'] for bucket in response[1]]
+
+    @RestClient.api_get('/{bucket_name}')
+    def bucket_exists(self, bucket_name, userid, request=None):
+        """
+        Check if the specified bucket exists for this user.
+        :param bucket_name: The name of the bucket.
+        :return: Returns True if the bucket exists, otherwise False.
+        """
+        # pylint: disable=unused-argument
+        try:
+            request()
+            my_buckets = self.get_buckets()
+            if bucket_name not in my_buckets:
+                raise RequestException(
+                    'Bucket "{}" belongs to other user'.format(bucket_name),
+                    403)
+            return True
+        except RequestException as e:
+            if e.status_code == 404:
+                return False
+            else:
+                raise e
+
+    @RestClient.api_put('/{bucket_name}')
+    def create_bucket(self, bucket_name, request=None):
+        logger.info("Creating bucket: %s", bucket_name)
+        return request()
index 22159694c6dc92b19043063634466df095c832e4..bb5c3c613098171746bb62b5be3f97cde9760129 100644 (file)
@@ -20,6 +20,20 @@ class Options(object):
     """
     ENABLE_BROWSABLE_API = (True, bool)
 
+    # RGW settings
+    RGW_API_HOST = ('', str)
+    RGW_API_PORT = (80, int)
+    RGW_API_ACCESS_KEY = ('', str)
+    RGW_API_SECRET_KEY = ('', str)
+    RGW_API_ADMIN_RESOURCE = ('admin', str)
+    RGW_API_SCHEME = ('http', str)
+    RGW_API_USER_ID = ('', str)
+
+    @staticmethod
+    def has_default_value(name):
+        return getattr(Settings, name, None) is None or \
+               getattr(Settings, name) == getattr(Options, name)[0]
+
 
 class SettingsMeta(type):
     def __getattr__(cls, attr):
index b5eb26f25454d3ccc36f4b8ef4ddf9e03b384757..7c82ea2136c740a0a07963018d0b680f58f1ae7b 100644 (file)
@@ -1,11 +1,14 @@
 # -*- coding: utf-8 -*-
 from __future__ import absolute_import
 
+import unittest
+
 from cherrypy.lib.sessions import RamSession
 from mock import patch
 
 from .helper import ControllerTestCase
 from ..controllers import RESTController, ApiController
+from ..tools import is_valid_ipv6_address, dict_contains_path
 
 
 # pylint: disable=W0613
@@ -132,3 +135,23 @@ class RESTControllerTest(ControllerTestCase):
                      headers=[('Accept', 'text/html'), ('Content-Length', '0')],
                      method='put')
         self.assertStatus(404)
+
+
+class TestFunctions(unittest.TestCase):
+
+    def test_is_valid_ipv6_address(self):
+        self.assertTrue(is_valid_ipv6_address('::'))
+        self.assertTrue(is_valid_ipv6_address('::1'))
+        self.assertFalse(is_valid_ipv6_address('127.0.0.1'))
+        self.assertFalse(is_valid_ipv6_address('localhost'))
+        self.assertTrue(is_valid_ipv6_address('1200:0000:AB00:1234:0000:2552:7777:1313'))
+        self.assertFalse(is_valid_ipv6_address('1200::AB00:1234::2552:7777:1313'))
+
+    def test_dict_contains_path(self):
+        x = {'a': {'b': {'c': 'foo'}}}
+        self.assertTrue(dict_contains_path(x, ['a', 'b', 'c']))
+        self.assertTrue(dict_contains_path(x, ['a', 'b', 'c']))
+        self.assertTrue(dict_contains_path(x, ['a']))
+        self.assertFalse(dict_contains_path(x, ['a', 'c']))
+
+        self.assertTrue(dict_contains_path(x, []))
index c531646c2250100bf5ea609c0d865874b6a14f21..802fd8af4deaf6033586868b7adb6b09f3e39353 100644 (file)
@@ -7,10 +7,11 @@ from datetime import datetime, timedelta
 import fnmatch
 import time
 import threading
-
+import socket
 import cherrypy
 
 from . import logger
+from six.moves import urllib
 
 
 class RequestLoggingTool(cherrypy.Tool):
@@ -625,3 +626,63 @@ class Task(object):
         self.progress = percentage
         if not in_lock:
             self.lock.release()
+
+
+def is_valid_ipv6_address(addr):
+    try:
+        socket.inet_pton(socket.AF_INET6, addr)
+        return True
+    except socket.error:
+        return False
+
+
+def build_url(host, scheme=None, port=None):
+    """
+    Build a valid URL. IPv6 addresses specified in host will be enclosed in brackets
+    automatically.
+
+    >>> build_url('example.com', 'https', 443)
+    'https://example.com:443'
+
+    >>> build_url(host='example.com', port=443)
+    '//example.com:443'
+
+    >>> build_url('fce:9af7:a667:7286:4917:b8d3:34df:8373', port=80, scheme='http')
+    'http://[fce:9af7:a667:7286:4917:b8d3:34df:8373]:80'
+
+    :param scheme: The scheme, e.g. http, https or ftp.
+    :type scheme: str
+    :param host: Consisting of either a registered name (including but not limited to
+                 a hostname) or an IP address.
+    :type host: str
+    :type port: int
+    :rtype: str
+    """
+    netloc = host if not is_valid_ipv6_address(host) else '[{}]'.format(host)
+    if port:
+        netloc += ':{}'.format(port)
+    pr = urllib.parse.ParseResult(
+        scheme=scheme if scheme else '',
+        netloc=netloc,
+        path='',
+        params='',
+        query='',
+        fragment='')
+    return pr.geturl()
+
+
+def dict_contains_path(dct, keys):
+    """
+    Tests wheter the keys exist recursively in `dictionary`.
+
+    :type dct: dict
+    :type keys: list
+    :rtype: bool
+    """
+    if keys:
+        key = keys.pop(0)
+        if key in dct:
+            dct = dct[key]
+            return dict_contains_path(dct, keys)
+        return False
+    return True
index 08dc57f823e885afd1bf4199f32f5296d6b84410..c8b01091735fe8ee4d370e58ee96edffe82820b8 100644 (file)
@@ -12,7 +12,7 @@ setenv=
     LD_LIBRARY_PATH = {toxinidir}/../../../../build/lib
     PATH = {toxinidir}/../../../../build/bin:$PATH
 commands=
-    {envbindir}/py.test --cov=. --cov-report= --junitxml=junit.{envname}.xml --doctest-modules controllers/rbd.py services/ tests/
+    {envbindir}/py.test --cov=. --cov-report= --junitxml=junit.{envname}.xml --doctest-modules controllers/rbd.py services/ tools.py tests/
 
 [testenv:cov-init]
 setenv =