From ccb1f7e04879afdf9af5fdfe8eff3876b86baf04 Mon Sep 17 00:00:00 2001 From: Ricardo Dias Date: Wed, 24 Jan 2018 17:08:04 +0000 Subject: [PATCH 1/7] mgr/dashboard_v2: pass json body keys as args to REST resource methods Signed-off-by: Ricardo Dias --- src/pybind/mgr/dashboard_v2/restresource.py | 33 +++++++++++++++++---- 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/src/pybind/mgr/dashboard_v2/restresource.py b/src/pybind/mgr/dashboard_v2/restresource.py index ecbd5c4b1d4b8..95bb9a47c1c9b 100644 --- a/src/pybind/mgr/dashboard_v2/restresource.py +++ b/src/pybind/mgr/dashboard_v2/restresource.py @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- +import inspect import json import cherrypy @@ -8,12 +10,26 @@ def inner(*args, **kwargs): content_length = int(cherrypy.request.headers['Content-Length']) body = cherrypy.request.body.read(content_length) if not body: - raise cherrypy.HTTPError(400, 'Empty body. Content-Length={}'.format(content_length)) + raise cherrypy.HTTPError(400, 'Empty body. Content-Length={}' + .format(content_length)) try: data = json.loads(body.decode('utf-8')) except Exception as e: - raise cherrypy.HTTPError(400, 'Failed to decode JSON: {}'.format(str(e))) - return func(data, *args, **kwargs) + raise cherrypy.HTTPError(400, 'Failed to decode JSON: {}' + .format(str(e))) + if hasattr(func, '_args_from_json_'): + f_args = inspect.getargspec(func).args + n_args = [] + for arg in args: + n_args.append(arg) + for arg in f_args[1:]: + if arg in data: + n_args.append(data[arg]) + data.pop(arg) + kwargs.update(data) + return func(*n_args, **kwargs) + else: + return func(data, *args, **kwargs) return inner @@ -89,12 +105,17 @@ def default(self, *vpath, **params): if not method: self._not_implemented(is_element) - if cherrypy.request.method != 'DELETE': - method = _returns_json(method) - if cherrypy.request.method not in ['GET', 'DELETE']: method = _takes_json(method) + if cherrypy.request.method != 'DELETE': + method = _returns_json(method) + cherrypy.response.status = status_code return method(*vpath, **params) + + @staticmethod + def args_from_json(func): + func._args_from_json_ = True + return func From 729d909864235f2059a03b334e77e9e83563e7c3 Mon Sep 17 00:00:00 2001 From: Ricardo Dias Date: Thu, 25 Jan 2018 11:41:24 +0000 Subject: [PATCH 2/7] mgr/dashboard_v2: Controller annotations Signed-off-by: Ricardo Dias --- src/pybind/mgr/dashboard_v2/tools.py | 66 ++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 src/pybind/mgr/dashboard_v2/tools.py diff --git a/src/pybind/mgr/dashboard_v2/tools.py b/src/pybind/mgr/dashboard_v2/tools.py new file mode 100644 index 0000000000000..043eb0c035046 --- /dev/null +++ b/src/pybind/mgr/dashboard_v2/tools.py @@ -0,0 +1,66 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import + +import importlib +import os +import pkgutil +import sys + + +def ApiController(path): + def decorate(cls): + cls._cp_controller_ = True + cls._cp_path_ = path + if not hasattr(cls, '_cp_config'): + cls._cp_config = { + 'tools.sessions.on': True, + 'tools.autenticate.on': False + } + else: + cls._cp_config['tools.sessions.on'] = True + if 'tools.autenticate.on' not in cls._cp_config: + cls._cp_config['tools.autenticate.on'] = False + return cls + return decorate + + +def AuthRequired(enabled=True): + def decorate(cls): + if not hasattr(cls, '_cp_config'): + cls._cp_config = { + 'tools.autenticate.on': enabled + } + else: + cls._cp_config['tools.autenticate.on'] = enabled + return cls + return decorate + + +def load_controllers(mgrmodule): + # setting sys.path properly when not running under the mgr + dashboard_dir = os.path.dirname(os.path.realpath(__file__)) + mgr_dir = os.path.dirname(dashboard_dir) + if mgr_dir not in sys.path: + sys.path.append(mgr_dir) + + controllers = [] + ctrls_path = "{}/controllers".format(dashboard_dir) + mods = [mod for _, mod, _ in pkgutil.iter_modules([ctrls_path])] + for mod_name in mods: + mod = importlib.import_module('.controllers.{}'.format(mod_name), + package='dashboard_v2') + for _, cls in mod.__dict__.items(): + if isinstance(cls, type) and hasattr(cls, '_cp_controller_'): + # found controller + cls._mgr_module_ = mgrmodule + controllers.append(cls) + + return controllers + + +def load_controller(mgrmodule, cls): + ctrls = load_controllers(mgrmodule) + for ctrl in ctrls: + if ctrl.__name__ == cls: + return ctrl + raise Exception("Controller class '{}' not found".format(cls)) From af594a46ad2d303dc83a06a627ae069852a68189 Mon Sep 17 00:00:00 2001 From: Ricardo Dias Date: Wed, 24 Jan 2018 14:20:25 +0000 Subject: [PATCH 3/7] msgr/dashboard_v2: auth module refactoring Signed-off-by: Ricardo Dias --- .../mgr/dashboard_v2/controllers/__init__.py | 0 .../dashboard_v2/{ => controllers}/auth.py | 61 +++++++------- src/pybind/mgr/dashboard_v2/module.py | 63 ++++---------- .../mgr/dashboard_v2/tests/test_auth.py | 82 +++++++++++-------- 4 files changed, 96 insertions(+), 110 deletions(-) create mode 100644 src/pybind/mgr/dashboard_v2/controllers/__init__.py rename src/pybind/mgr/dashboard_v2/{ => controllers}/auth.py (74%) diff --git a/src/pybind/mgr/dashboard_v2/controllers/__init__.py b/src/pybind/mgr/dashboard_v2/controllers/__init__.py new file mode 100644 index 0000000000000..e69de29bb2d1d diff --git a/src/pybind/mgr/dashboard_v2/auth.py b/src/pybind/mgr/dashboard_v2/controllers/auth.py similarity index 74% rename from src/pybind/mgr/dashboard_v2/auth.py rename to src/pybind/mgr/dashboard_v2/controllers/auth.py index 0b6d7eaea8399..7b0263ece05d4 100644 --- a/src/pybind/mgr/dashboard_v2/auth.py +++ b/src/pybind/mgr/dashboard_v2/controllers/auth.py @@ -1,14 +1,17 @@ # -*- coding: utf-8 -*- +from __future__ import absolute_import import bcrypt import cherrypy import time import sys -from cherrypy import tools +from ..restresource import RESTResource +from ..tools import ApiController, AuthRequired -class Auth(object): +@ApiController('auth') +class Auth(RESTResource): """ Provide login and logout actions. @@ -27,55 +30,53 @@ class Auth(object): DEFAULT_SESSION_EXPIRE = 1200.0 - @staticmethod - def password_hash(password, salt_password=None): - if not salt_password: - salt_password = bcrypt.gensalt() - if sys.version_info > (3, 0): - return bcrypt.hashpw(password, salt_password) - else: - return bcrypt.hashpw(password.encode('utf8'), salt_password) + def __init__(self): + self._mod = Auth._mgr_module_ + self._log = self._mod.log - def __init__(self, module): - self.module = module - self.log = self.module.log - - @cherrypy.expose - @cherrypy.tools.allow(methods=['POST']) - @tools.json_out() - def login(self, username=None, password=None): + @RESTResource.args_from_json + def create(self, username, password): now = time.time() - config_username = self.module.get_localized_config('username', None) - config_password = self.module.get_localized_config('password', None) + config_username = self._mod.get_localized_config('username', None) + config_password = self._mod.get_localized_config('password', None) hash_password = Auth.password_hash(password, config_password) if username == config_username and hash_password == config_password: cherrypy.session.regenerate() cherrypy.session[Auth.SESSION_KEY] = username cherrypy.session[Auth.SESSION_KEY_TS] = now - self.log.debug('Login successful') + self._log.debug("Login successful") return {'username': username} else: cherrypy.response.status = 403 - self.log.debug('Login fail') + self._log.debug("Login fail") return {'detail': 'Invalid credentials'} - @cherrypy.expose - @cherrypy.tools.allow(methods=['POST']) - def logout(self): - self.log.debug('Logout successful') + def bulk_delete(self): + self._log.debug("Logout successful") cherrypy.session[Auth.SESSION_KEY] = None cherrypy.session[Auth.SESSION_KEY_TS] = None - def check_auth(self): + @staticmethod + def password_hash(password, salt_password=None): + if not salt_password: + salt_password = bcrypt.gensalt() + if sys.version_info > (3, 0): + return bcrypt.hashpw(password, salt_password) + else: + return bcrypt.hashpw(password.encode('utf8'), salt_password) + + @staticmethod + def check_auth(): + module = Auth._mgr_module_ username = cherrypy.session.get(Auth.SESSION_KEY) if not username: - self.log.debug('Unauthorized access to {}'.format(cherrypy.url( + module.log.debug('Unauthorized access to {}'.format(cherrypy.url( relative='server'))) raise cherrypy.HTTPError(401, 'You are not authorized to access ' 'that resource') now = time.time() - expires = float(self.module.get_localized_config( + expires = float(module.get_localized_config( 'session-expire', Auth.DEFAULT_SESSION_EXPIRE)) if expires > 0: @@ -83,7 +84,7 @@ def check_auth(self): if username_ts and float(username_ts) < (now - expires): cherrypy.session[Auth.SESSION_KEY] = None cherrypy.session[Auth.SESSION_KEY_TS] = None - self.log.debug('Session expired.') + module.log.debug("Session expired.") raise cherrypy.HTTPError(401, 'Session expired. You are not ' 'authorized to access that resource') diff --git a/src/pybind/mgr/dashboard_v2/module.py b/src/pybind/mgr/dashboard_v2/module.py index da032bd1c0483..4029d86ead43a 100644 --- a/src/pybind/mgr/dashboard_v2/module.py +++ b/src/pybind/mgr/dashboard_v2/module.py @@ -8,10 +8,11 @@ import os import cherrypy from cherrypy import tools - -from .auth import Auth from mgr_module import MgrModule +from .controllers.auth import Auth +from .tools import load_controllers + # cherrypy likes to sys.exit on error. don't let it take us down too! def os_exit_noop(*args): @@ -54,24 +55,10 @@ def serve(self): 'server.socket_host': server_addr, 'server.socket_port': int(server_port), }) - auth = Auth(self) cherrypy.tools.autenticate = cherrypy.Tool('before_handler', - auth.check_auth) - noauth_required_config = { - '/': { - 'tools.autenticate.on': False, - 'tools.sessions.on': True - } - } - auth_required_config = { - '/': { - 'tools.autenticate.on': True, - 'tools.sessions.on': True - } - } - cherrypy.tree.mount(auth, "/api/auth", config=noauth_required_config) - cherrypy.tree.mount(Module.HelloWorld(self), "/api/hello", - config=auth_required_config) + Auth.check_auth) + + cherrypy.tree.mount(Module.ApiRoot(self), "/api") cherrypy.engine.start() self.log.info("Waiting for engine...") cherrypy.engine.block() @@ -92,32 +79,12 @@ def handle_command(self, cmd): return (-errno.EINVAL, '', 'Command not found \'{0}\''.format( cmd['prefix'])) - class HelloWorld(object): - - """ - - Hello World. - - """ - - def __init__(self, module): - self.module = module - self.log = module.log - self.log.warn("Initiating WebServer CherryPy") - - @cherrypy.expose - def index(self): - """ - WS entrypoint - """ - - return "Hello World!" - - @cherrypy.expose - @tools.json_out() - def ping(self): - """ - Ping endpoint - """ - - return "pong" + class ApiRoot(object): + def __init__(self, mgrmod): + ctrls = load_controllers(mgrmod) + mgrmod.log.debug("loaded controllers: {}".format(ctrls)) + for ctrl in ctrls: + mgrmod.log.warn("adding controller: {} -> {}" + .format(ctrl.__name__, ctrl._cp_path_)) + ins = ctrl() + setattr(Module.ApiRoot, ctrl._cp_path_, ins) diff --git a/src/pybind/mgr/dashboard_v2/tests/test_auth.py b/src/pybind/mgr/dashboard_v2/tests/test_auth.py index 50a12ca5cae9c..3b007ac4d62b0 100644 --- a/src/pybind/mgr/dashboard_v2/tests/test_auth.py +++ b/src/pybind/mgr/dashboard_v2/tests/test_auth.py @@ -2,13 +2,15 @@ from __future__ import absolute_import +import json import time + from cherrypy.lib.sessions import RamSession from cherrypy.test import helper from mock import patch -from ..auth import Auth from ..module import Module, cherrypy +from ..tools import load_controller class Ping(object): @cherrypy.expose @@ -16,71 +18,87 @@ class Ping(object): def ping(self): pass + class AuthTest(helper.CPWebCase): @staticmethod def setup_server(): module = Module('dashboard', None, None) - auth = Auth(module) - cherrypy.tools.autenticate = cherrypy.Tool('before_handler', auth.check_auth) - cherrypy.tree.mount(auth, "/api/auth") + AuthTest.Auth = load_controller(module, 'Auth') + + cherrypy.tools.autenticate = cherrypy.Tool('before_handler', + AuthTest.Auth.check_auth) + + cherrypy.tree.mount(AuthTest.Auth(), "/api/auth") cherrypy.tree.mount(Ping(), "/api/test", config={'/': {'tools.autenticate.on': True}}) module.set_localized_config('session-expire','0.5') module.set_localized_config('username','admin') - pass_hash = Auth.password_hash('admin') + pass_hash = AuthTest.Auth.password_hash('admin') module.set_localized_config('password', pass_hash) + def _request(self, url, method, data=None): + if not data: + b = None + h = None + else: + b = json.dumps(data) + h = [('Content-Type', 'application/json'), + ('Content-Length', str(len(b)))] + self.getPage(url, method=method, body=b, headers=h) + + def _post(self, url, data=None): + self._request(url, 'POST', data) + + def _delete(self, url, data=None): + self._request(url, 'DELETE', data) + def test_login_valid(self): sess_mock = RamSession() with patch('cherrypy.session', sess_mock, create=True): - self.getPage("/api/auth/login", - body="username=admin&password=admin", - method='POST') - self.assertStatus('200 OK') + self._post("/api/auth", {'username': 'admin', 'password': 'admin'}) + self.assertStatus('201 Created') self.assertBody('{"username": "admin"}') - self.assertEquals(sess_mock.get(Auth.SESSION_KEY), 'admin') + self.assertEquals(sess_mock.get(AuthTest.Auth.SESSION_KEY), + 'admin') def test_login_invalid(self): sess_mock = RamSession() with patch('cherrypy.session', sess_mock, create=True): - self.getPage("/api/auth/login", - body="username=admin&password=invalid", - method='POST') + self._post("/api/auth", {'username': 'admin', 'password': 'inval'}) self.assertStatus('403 Forbidden') self.assertBody('{"detail": "Invalid credentials"}') - self.assertEquals(sess_mock.get(Auth.SESSION_KEY), None) + self.assertEquals(sess_mock.get(AuthTest.Auth.SESSION_KEY), None) def test_logout(self): sess_mock = RamSession() with patch('cherrypy.session', sess_mock, create=True): - self.getPage("/api/auth/login", - body="username=admin&password=admin", - method='POST') - self.assertEquals(sess_mock.get(Auth.SESSION_KEY), 'admin') - self.getPage("/api/auth/logout", method='POST') - self.assertStatus('200 OK') + self._post("/api/auth", {'username': 'admin', 'password': 'admin'}) + self.assertEquals(sess_mock.get(AuthTest.Auth.SESSION_KEY), + 'admin') + self._delete("/api/auth") + self.assertStatus('204 No Content') self.assertBody('') - self.assertEquals(sess_mock.get(Auth.SESSION_KEY), None) + self.assertEquals(sess_mock.get(AuthTest.Auth.SESSION_KEY), None) def test_session_expire(self): sess_mock = RamSession() with patch('cherrypy.session', sess_mock, create=True): - self.getPage("/api/auth/login", - body="username=admin&password=admin", - method='POST') - self.assertStatus('200 OK') - self.assertEquals(sess_mock.get(Auth.SESSION_KEY), 'admin') - self.getPage("/api/test/ping", method='POST') + self._post("/api/auth", {'username': 'admin', 'password': 'admin'}) + self.assertStatus('201 Created') + self.assertEquals(sess_mock.get(AuthTest.Auth.SESSION_KEY), + 'admin') + self._post("/api/test/ping") self.assertStatus('200 OK') - self.assertEquals(sess_mock.get(Auth.SESSION_KEY), 'admin') + self.assertEquals(sess_mock.get(AuthTest.Auth.SESSION_KEY), + 'admin') time.sleep(1) - self.getPage("/api/test/ping", method='POST') + self._post("/api/test/ping") self.assertStatus('401 Unauthorized') - self.assertEquals(sess_mock.get(Auth.SESSION_KEY), None) + self.assertEquals(sess_mock.get(AuthTest.Auth.SESSION_KEY), None) def test_unauthorized(self): sess_mock = RamSession() with patch('cherrypy.session', sess_mock, create=True): - self.getPage("/api/test/ping", method='POST') + self._post("/api/test/ping") self.assertStatus('401 Unauthorized') - self.assertEquals(sess_mock.get(Auth.SESSION_KEY), None) + self.assertEquals(sess_mock.get(AuthTest.Auth.SESSION_KEY), None) From bda8defd0adce934228a4faa5a03a3986c968e73 Mon Sep 17 00:00:00 2001 From: Ricardo Dias Date: Thu, 25 Jan 2018 11:44:01 +0000 Subject: [PATCH 4/7] mgr/dashboard_v2: Very simple ping example Signed-off-by: Ricardo Dias --- .../mgr/dashboard_v2/controllers/ping.py | 28 +++++++++++++ .../mgr/dashboard_v2/tests/test_ping.py | 40 +++++++++++++++++-- 2 files changed, 64 insertions(+), 4 deletions(-) create mode 100644 src/pybind/mgr/dashboard_v2/controllers/ping.py diff --git a/src/pybind/mgr/dashboard_v2/controllers/ping.py b/src/pybind/mgr/dashboard_v2/controllers/ping.py new file mode 100644 index 0000000000000..f3d96d85700e9 --- /dev/null +++ b/src/pybind/mgr/dashboard_v2/controllers/ping.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import + +import cherrypy + +from ..restresource import RESTResource +from ..tools import ApiController, AuthRequired + + +@ApiController('ping') +@AuthRequired() +class Ping(object): + @cherrypy.expose + def default(self, *args): + return "pong" + + +@ApiController('echo1') +class EchoArgs(RESTResource): + @RESTResource.args_from_json + def create(self, msg): + return {'echo': msg} + + +@ApiController('echo2') +class Echo(RESTResource): + def create(self, data): + return {'echo': data['msg']} diff --git a/src/pybind/mgr/dashboard_v2/tests/test_ping.py b/src/pybind/mgr/dashboard_v2/tests/test_ping.py index cca2241ba9779..721431b2f267e 100644 --- a/src/pybind/mgr/dashboard_v2/tests/test_ping.py +++ b/src/pybind/mgr/dashboard_v2/tests/test_ping.py @@ -2,18 +2,50 @@ from __future__ import absolute_import +import json + from cherrypy.test import helper +from ..controllers.auth import Auth from ..module import Module, cherrypy +from ..tools import load_controller class SimpleCPTest(helper.CPWebCase): @staticmethod def setup_server(): + + cherrypy.tools.autenticate = cherrypy.Tool('before_handler', + Auth.check_auth) module = Module('attic', None, None) - cherrypy.tree.mount(Module.HelloWorld(module), "/api/hello") + Ping = load_controller(module, 'Ping') + Echo = load_controller(module, 'Echo') + EchoArgs = load_controller(module, 'EchoArgs') + cherrypy.tree.mount(Ping(), "/api/ping") + cherrypy.tree.mount(Echo(), "/api/echo2") + cherrypy.tree.mount(EchoArgs(), "/api/echo1") + + def _request(self, url, method, data=None): + if not data: + b = None + h = None + else: + b = json.dumps(data) + h = [('Content-Type', 'application/json'), + ('Content-Length', str(len(b)))] + self.getPage(url, method=method, body=b, headers=h) + + def _post(self, url, data=None): + self._request(url, 'POST', data) def test_ping(self): - self.getPage("/api/hello/ping") - self.assertStatus('200 OK') - self.assertBody('"pong"') + self.getPage("/api/ping") + self.assertStatus('401 Unauthorized') + + def test_echo(self): + self._post("/api/echo2", {'msg': 'Hello World'}) + self.assertStatus('201 Created') + + def test_echo_args(self): + self._post("/api/echo1", {'msg': 'Hello World'}) + self.assertStatus('201 Created') From 990f6d36326f51633140b5015de03399e05329ca Mon Sep 17 00:00:00 2001 From: Ricardo Dias Date: Thu, 25 Jan 2018 12:13:41 +0000 Subject: [PATCH 5/7] mgr/dashboard_v2: Added developer notes to README Signed-off-by: Ricardo Dias --- src/pybind/mgr/dashboard_v2/README.rst | 64 ++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/src/pybind/mgr/dashboard_v2/README.rst b/src/pybind/mgr/dashboard_v2/README.rst index a5cca82fb0f79..1b9853835f7c7 100644 --- a/src/pybind/mgr/dashboard_v2/README.rst +++ b/src/pybind/mgr/dashboard_v2/README.rst @@ -83,7 +83,71 @@ is located):: $ tox + If you just want to run a single tox environment, for instance only run the linting tools:: $ tox -e lint + +Developer Notes +--------------- + +How to add a new controller? +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If you want to add a new endpoint to the backend, you just need to add +a class decorated with ``ApiController`` in a python file located under the +``controllers`` directory. The dashboard plugin will automatically load your +new controller upon start. + +For example create a file ``ping2.py`` under ``controllers`` directory with the +following code:: + + import cherrypy + from ..tools import ApiController + + @ApiController('ping2') + class Ping2(object): + @cherrypy.expose + def default(self, *args): + return "Hello" + +Reload the dashboard plugin, and then you can access the above controller +from the web browser using the URL http://mgr_hostname:8080/api/ping2 + +We also provide a simple mechanism to create REST based controllers using the +``RESTResource`` class. + +For example, we can adapt the above controller to return JSON when accessing +the endpoint with a GET request:: + + import cherrypy + from ..restresource import RESTResource + from ..tools import ApiController + + @ApiController('ping2') + class Ping2(RESTResource): + def list(self): + return {"msg": "Hello"} + + +How to restrict access to a controller? +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If you require that only authenticated users can access you controller, just +add the ``AuthRequired`` decorator to your controller class. + +Example:: + + import cherrypy + from ..restresource import RESTResource + from ..tools import ApiController, AuthRequired + + @ApiController('ping2') + @AuthRequired + class Ping2(RESTResource): + def list(self): + return {"msg": "Hello"} + +Now only authenticated users will be able to "ping" your controller. + From 01742c81cbfa719c20877483617218d71d131127 Mon Sep 17 00:00:00 2001 From: Ricardo Dias Date: Thu, 25 Jan 2018 12:16:39 +0000 Subject: [PATCH 6/7] mgr/dashboard_v2: Increased test_auth session_expire to avoid false negatives Signed-off-by: Ricardo Dias --- src/pybind/mgr/dashboard_v2/tests/test_auth.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pybind/mgr/dashboard_v2/tests/test_auth.py b/src/pybind/mgr/dashboard_v2/tests/test_auth.py index 3b007ac4d62b0..bde0b72e23589 100644 --- a/src/pybind/mgr/dashboard_v2/tests/test_auth.py +++ b/src/pybind/mgr/dashboard_v2/tests/test_auth.py @@ -31,7 +31,7 @@ def setup_server(): cherrypy.tree.mount(AuthTest.Auth(), "/api/auth") cherrypy.tree.mount(Ping(), "/api/test", config={'/': {'tools.autenticate.on': True}}) - module.set_localized_config('session-expire','0.5') + module.set_localized_config('session-expire','2') module.set_localized_config('username','admin') pass_hash = AuthTest.Auth.password_hash('admin') module.set_localized_config('password', pass_hash) @@ -91,7 +91,7 @@ def test_session_expire(self): self.assertStatus('200 OK') self.assertEquals(sess_mock.get(AuthTest.Auth.SESSION_KEY), 'admin') - time.sleep(1) + time.sleep(3) self._post("/api/test/ping") self.assertStatus('401 Unauthorized') self.assertEquals(sess_mock.get(AuthTest.Auth.SESSION_KEY), None) From e53f90cda6721ebba2701442cb6d65e502f90b1c Mon Sep 17 00:00:00 2001 From: Ricardo Dias Date: Thu, 25 Jan 2018 14:28:04 +0000 Subject: [PATCH 7/7] mgr/dashboard_v2: Renamed/moved RESTResource to RESTController Signed-off-by: Ricardo Dias --- src/pybind/mgr/dashboard_v2/README.rst | 12 +- .../mgr/dashboard_v2/controllers/auth.py | 7 +- .../mgr/dashboard_v2/controllers/ping.py | 9 +- src/pybind/mgr/dashboard_v2/restresource.py | 121 ----------------- src/pybind/mgr/dashboard_v2/tools.py | 122 ++++++++++++++++++ 5 files changed, 134 insertions(+), 137 deletions(-) delete mode 100644 src/pybind/mgr/dashboard_v2/restresource.py diff --git a/src/pybind/mgr/dashboard_v2/README.rst b/src/pybind/mgr/dashboard_v2/README.rst index 1b9853835f7c7..fe324d380f532 100644 --- a/src/pybind/mgr/dashboard_v2/README.rst +++ b/src/pybind/mgr/dashboard_v2/README.rst @@ -116,17 +116,16 @@ Reload the dashboard plugin, and then you can access the above controller from the web browser using the URL http://mgr_hostname:8080/api/ping2 We also provide a simple mechanism to create REST based controllers using the -``RESTResource`` class. +``RESTController`` class. For example, we can adapt the above controller to return JSON when accessing the endpoint with a GET request:: import cherrypy - from ..restresource import RESTResource - from ..tools import ApiController + from ..tools import ApiController, RESTController @ApiController('ping2') - class Ping2(RESTResource): + class Ping2(RESTController): def list(self): return {"msg": "Hello"} @@ -140,12 +139,11 @@ add the ``AuthRequired`` decorator to your controller class. Example:: import cherrypy - from ..restresource import RESTResource - from ..tools import ApiController, AuthRequired + from ..tools import ApiController, AuthRequired, RESTController @ApiController('ping2') @AuthRequired - class Ping2(RESTResource): + class Ping2(RESTController): def list(self): return {"msg": "Hello"} diff --git a/src/pybind/mgr/dashboard_v2/controllers/auth.py b/src/pybind/mgr/dashboard_v2/controllers/auth.py index 7b0263ece05d4..7ce3f87899ce6 100644 --- a/src/pybind/mgr/dashboard_v2/controllers/auth.py +++ b/src/pybind/mgr/dashboard_v2/controllers/auth.py @@ -6,12 +6,11 @@ import time import sys -from ..restresource import RESTResource -from ..tools import ApiController, AuthRequired +from ..tools import ApiController, AuthRequired, RESTController @ApiController('auth') -class Auth(RESTResource): +class Auth(RESTController): """ Provide login and logout actions. @@ -34,7 +33,7 @@ def __init__(self): self._mod = Auth._mgr_module_ self._log = self._mod.log - @RESTResource.args_from_json + @RESTController.args_from_json def create(self, username, password): now = time.time() config_username = self._mod.get_localized_config('username', None) diff --git a/src/pybind/mgr/dashboard_v2/controllers/ping.py b/src/pybind/mgr/dashboard_v2/controllers/ping.py index f3d96d85700e9..28ef35cd50462 100644 --- a/src/pybind/mgr/dashboard_v2/controllers/ping.py +++ b/src/pybind/mgr/dashboard_v2/controllers/ping.py @@ -3,8 +3,7 @@ import cherrypy -from ..restresource import RESTResource -from ..tools import ApiController, AuthRequired +from ..tools import ApiController, AuthRequired, RESTController @ApiController('ping') @@ -16,13 +15,13 @@ def default(self, *args): @ApiController('echo1') -class EchoArgs(RESTResource): - @RESTResource.args_from_json +class EchoArgs(RESTController): + @RESTController.args_from_json def create(self, msg): return {'echo': msg} @ApiController('echo2') -class Echo(RESTResource): +class Echo(RESTController): def create(self, data): return {'echo': data['msg']} diff --git a/src/pybind/mgr/dashboard_v2/restresource.py b/src/pybind/mgr/dashboard_v2/restresource.py deleted file mode 100644 index 95bb9a47c1c9b..0000000000000 --- a/src/pybind/mgr/dashboard_v2/restresource.py +++ /dev/null @@ -1,121 +0,0 @@ -# -*- coding: utf-8 -*- -import inspect -import json - -import cherrypy - - -def _takes_json(func): - def inner(*args, **kwargs): - content_length = int(cherrypy.request.headers['Content-Length']) - body = cherrypy.request.body.read(content_length) - if not body: - raise cherrypy.HTTPError(400, 'Empty body. Content-Length={}' - .format(content_length)) - try: - data = json.loads(body.decode('utf-8')) - except Exception as e: - raise cherrypy.HTTPError(400, 'Failed to decode JSON: {}' - .format(str(e))) - if hasattr(func, '_args_from_json_'): - f_args = inspect.getargspec(func).args - n_args = [] - for arg in args: - n_args.append(arg) - for arg in f_args[1:]: - if arg in data: - n_args.append(data[arg]) - data.pop(arg) - kwargs.update(data) - return func(*n_args, **kwargs) - else: - return func(data, *args, **kwargs) - return inner - - -def _returns_json(func): - def inner(*args, **kwargs): - cherrypy.serving.response.headers['Content-Type'] = 'application/json' - ret = func(*args, **kwargs) - return json.dumps(ret).encode('utf8') - return inner - - -def json_error_page(status, message, traceback, version): - return json.dumps(dict(status=status, detail=message, traceback=traceback, - version=version)) - - -class RESTResource(object): - """ - Base class for providing a RESTful interface to a resource. - - To use this class, simply derive a class from it and implement the methods - you want to support. The list of possible methods are: - - * list() - * bulk_set(data) - * create(data) - * bulk_delete() - * get(key) - * set(data, key) - * delete(key) - - Test with curl: - - curl -H "Content-Type: application/json" -X POST \ - -d '{"username":"xyz","password":"xyz"}' http://127.0.0.1:8080/foo - curl http://127.0.0.1:8080/foo - curl http://127.0.0.1:8080/foo/0 - - """ - - _cp_config = { - 'request.error_page': {'default': json_error_page}, - } - - def _not_implemented(self, is_element): - methods = [method - for ((method, _is_element), (meth, _)) - in self._method_mapping.items() - if _is_element == is_element and hasattr(self, meth)] - cherrypy.response.headers['Allow'] = ','.join(methods) - raise cherrypy.HTTPError(405, 'Method not implemented.') - - _method_mapping = { - ('GET', False): ('list', 200), - ('PUT', False): ('bulk_set', 200), - ('PATCH', False): ('bulk_set', 200), - ('POST', False): ('create', 201), - ('DELETE', False): ('bulk_delete', 204), - ('GET', True): ('get', 200), - ('PUT', True): ('set', 200), - ('PATCH', True): ('set', 200), - ('DELETE', True): ('delete', 204), - } - - @cherrypy.expose - def default(self, *vpath, **params): - cherrypy.config.update({'error_page.default': json_error_page}) - is_element = len(vpath) > 0 - - (method_name, status_code) = self._method_mapping[ - (cherrypy.request.method, is_element)] - method = getattr(self, method_name, None) - if not method: - self._not_implemented(is_element) - - if cherrypy.request.method not in ['GET', 'DELETE']: - method = _takes_json(method) - - if cherrypy.request.method != 'DELETE': - method = _returns_json(method) - - cherrypy.response.status = status_code - - return method(*vpath, **params) - - @staticmethod - def args_from_json(func): - func._args_from_json_ = True - return func diff --git a/src/pybind/mgr/dashboard_v2/tools.py b/src/pybind/mgr/dashboard_v2/tools.py index 043eb0c035046..a27b995e6f8a2 100644 --- a/src/pybind/mgr/dashboard_v2/tools.py +++ b/src/pybind/mgr/dashboard_v2/tools.py @@ -2,10 +2,14 @@ from __future__ import absolute_import import importlib +import inspect +import json import os import pkgutil import sys +import cherrypy + def ApiController(path): def decorate(cls): @@ -64,3 +68,121 @@ def load_controller(mgrmodule, cls): if ctrl.__name__ == cls: return ctrl raise Exception("Controller class '{}' not found".format(cls)) + + +def _json_error_page(status, message, traceback, version): + return json.dumps(dict(status=status, detail=message, traceback=traceback, + version=version)) + + +class RESTController(object): + """ + Base class for providing a RESTful interface to a resource. + + To use this class, simply derive a class from it and implement the methods + you want to support. The list of possible methods are: + + * list() + * bulk_set(data) + * create(data) + * bulk_delete() + * get(key) + * set(data, key) + * delete(key) + + Test with curl: + + curl -H "Content-Type: application/json" -X POST \ + -d '{"username":"xyz","password":"xyz"}' http://127.0.0.1:8080/foo + curl http://127.0.0.1:8080/foo + curl http://127.0.0.1:8080/foo/0 + + """ + + _cp_config = { + 'request.error_page': {'default': _json_error_page}, + } + + def _not_implemented(self, is_element): + methods = [method + for ((method, _is_element), (meth, _)) + in self._method_mapping.items() + if _is_element == is_element and hasattr(self, meth)] + cherrypy.response.headers['Allow'] = ','.join(methods) + raise cherrypy.HTTPError(405, 'Method not implemented.') + + _method_mapping = { + ('GET', False): ('list', 200), + ('PUT', False): ('bulk_set', 200), + ('PATCH', False): ('bulk_set', 200), + ('POST', False): ('create', 201), + ('DELETE', False): ('bulk_delete', 204), + ('GET', True): ('get', 200), + ('PUT', True): ('set', 200), + ('PATCH', True): ('set', 200), + ('DELETE', True): ('delete', 204), + } + + @cherrypy.expose + def default(self, *vpath, **params): + cherrypy.config.update({ + 'error_page.default': _json_error_page}) + is_element = len(vpath) > 0 + + (method_name, status_code) = self._method_mapping[ + (cherrypy.request.method, is_element)] + method = getattr(self, method_name, None) + if not method: + self._not_implemented(is_element) + + if cherrypy.request.method not in ['GET', 'DELETE']: + method = RESTController._takes_json(method) + + if cherrypy.request.method != 'DELETE': + method = RESTController._returns_json(method) + + cherrypy.response.status = status_code + + return method(*vpath, **params) + + @staticmethod + def args_from_json(func): + func._args_from_json_ = True + return func + + @staticmethod + def _takes_json(func): + def inner(*args, **kwargs): + content_length = int(cherrypy.request.headers['Content-Length']) + body = cherrypy.request.body.read(content_length) + if not body: + raise cherrypy.HTTPError(400, 'Empty body. Content-Length={}' + .format(content_length)) + try: + data = json.loads(body.decode('utf-8')) + except Exception as e: + raise cherrypy.HTTPError(400, 'Failed to decode JSON: {}' + .format(str(e))) + if hasattr(func, '_args_from_json_'): + f_args = inspect.getargspec(func).args + n_args = [] + for arg in args: + n_args.append(arg) + for arg in f_args[1:]: + if arg in data: + n_args.append(data[arg]) + data.pop(arg) + kwargs.update(data) + return func(*n_args, **kwargs) + else: + return func(data, *args, **kwargs) + return inner + + @staticmethod + def _returns_json(func): + def inner(*args, **kwargs): + cherrypy.serving.response.headers['Content-Type'] = \ + 'application/json' + ret = func(*args, **kwargs) + return json.dumps(ret).encode('utf8') + return inner