[PATCH v3] [WoK 0/2] /config/plugins API implementation

From: Daniel Henrique Barboza <danielhb@linux.vnet.ibm.com> v3: - atomic backend commit - using regex on parser - 'test_mode' is now being retrieved by wok config - plug-in dependencies are now being fetched - plug-ins are now being enabled/disabled the cherrypy tree v2: - added User Log capabilities on /config/plugins/enable|disable actions - added 'enable=' as a valid entry in the parsing of the conf file This patch set implements the '/config/plugins' API. The idea of this API is to replace the current '/plugins' API while adding new attributes in their return values: - enabled: true if the plug-in is enabled, false otherwise - depends: list of all the plug-ins that this plug-in depends on - is_dependency_of: list of all plug-in that depends on this plug-in This backend is capable of enabling/disabling the plugi-ns using the API /config/plugins/*name*/enable|disable. Please check the commit messages of each patch for further details. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Daniel Henrique Barboza (2): /config/plugins API: backend changes /config/plugins: changing existing UI calls docs/API/config.md | 32 +++++++ docs/API/plugins.md | 13 --- src/wok/config.py.in | 5 +- src/wok/control/config.py | 31 ++++++- src/wok/control/plugins.py | 29 ------ src/wok/i18n.py | 4 + src/wok/model/plugins.py | 40 ++++++-- src/wok/server.py | 56 ++--------- src/wok/utils.py | 227 +++++++++++++++++++++++++++++++++++++++++++-- tests/test_api.py | 59 ++++++++++++ tests/test_utils.py | 75 ++++++++++++++- ui/js/src/wok.api.js | 4 +- ui/js/src/wok.logos.js | 11 ++- ui/js/src/wok.main.js | 10 +- 14 files changed, 476 insertions(+), 120 deletions(-) delete mode 100644 docs/API/plugins.md delete mode 100644 src/wok/control/plugins.py -- 2.9.3

From: Daniel Henrique Barboza <danielhb@linux.vnet.ibm.com> This patch adds a backend for a new API called /config/plugins. The idea is to be able to retrieve the 'enable' status of WoK plug-ins and also provide a way to enable/disable them. The enable|disable operation consists on two steps: - changing the 'enable=' attribute of the [WoK] section of the plugin .conf file; - the plug-in is removed/added in the cherrypy.tree on the fly. Several changes/enhancements in the backend were made to make this possible, such as: - added the 'test' parameter in the config.py.in file to make it available for reading in the backend. This parameter indicates whether WoK is running in test mode; - 'load_plugin' was moved from server.py to utils.py to make it available for utils functions to load plug-ins; - a new 'depends' attribute is now being considered in the root class of each plug-in. This is an array that indicates all the plug-ins it has a dependency on. For example, Kimchi would mark self.depends = ['gingerbase'] in its root file. The absence of this attribute means that the plug-in does not have any dependency aside from WoK. Previous /plugins API were removed because it was redundant with this work. Uni tests included. Signed-off-by: Daniel Henrique Barboza <danielhb@linux.vnet.ibm.com> --- docs/API/config.md | 32 +++++++ docs/API/plugins.md | 13 --- src/wok/config.py.in | 5 +- src/wok/control/config.py | 31 ++++++- src/wok/control/plugins.py | 29 ------ src/wok/i18n.py | 4 + src/wok/model/plugins.py | 40 ++++++-- src/wok/server.py | 56 ++--------- src/wok/utils.py | 227 +++++++++++++++++++++++++++++++++++++++++++-- tests/test_api.py | 59 ++++++++++++ tests/test_utils.py | 75 ++++++++++++++- 11 files changed, 460 insertions(+), 111 deletions(-) delete mode 100644 docs/API/plugins.md delete mode 100644 src/wok/control/plugins.py diff --git a/docs/API/config.md b/docs/API/config.md index 4ba455e..87619ac 100644 --- a/docs/API/config.md +++ b/docs/API/config.md @@ -26,3 +26,35 @@ GET /config websockets_port: 64667, version: 2.0 } + +### Collection: Plugins + +**URI:** /config/plugins + +**Methods:** + +* **GET**: Retrieve a summarized list of all UI Plugins. + +#### Examples +GET /plugins +[{'name': 'pluginA', 'enabled': True, "depends":['pluginB'], "is_dependency_of":[]}, + {'name': 'pluginB', 'enabled': False, "depends":[], "is_dependency_of":['pluginA']}] + +### Resource: Plugins + +**URI:** /config/plugins/*:name* + +Represents the current state of a given WoK plug-in. + +**Methods:** + +* **GET**: Retrieve the state of the plug-in. + * name: The name of the plug-in. + * enabled: True if the plug-in is currently enabled in WoK, False otherwise. + +* **POST**: *See Plugin Actions* + +**Actions (POST):** + +* enable: Enable the plug-in in the configuration file. +* disable: Disable the plug-in in the configuration file. diff --git a/docs/API/plugins.md b/docs/API/plugins.md deleted file mode 100644 index aaa37b5..0000000 --- a/docs/API/plugins.md +++ /dev/null @@ -1,13 +0,0 @@ -## REST API Specification for Plugins - -### Collection: Plugins - -**URI:** /plugins - -**Methods:** - -* **GET**: Retrieve a summarized list names of all UI Plugins - -#### Examples -GET /plugins -[pluginA, pluginB, pluginC] diff --git a/src/wok/config.py.in b/src/wok/config.py.in index 9573e66..0e46b17 100644 --- a/src/wok/config.py.in +++ b/src/wok/config.py.in @@ -1,7 +1,7 @@ # # Project Wok # -# Copyright IBM Corp, 2015-2016 +# Copyright IBM Corp, 2015-2017 # # Code derived from Project Kimchi # @@ -269,6 +269,7 @@ def _get_config(): config.set("server", "environment", "production") config.set('server', 'max_body_size', '4*1024*1024') config.set("server", "server_root", "") + config.set("server", "test", "true") config.add_section("authentication") config.set("authentication", "method", "pam") config.set("authentication", "ldap_server", "") @@ -278,6 +279,8 @@ def _get_config(): config.add_section("logging") config.set("logging", "log_dir", paths.log_dir) config.set("logging", "log_level", DEFAULT_LOG_LEVEL) + config.set("logging", "access_log", "") + config.set("logging", "error_log", "") config_file = os.path.join(paths.conf_dir, 'wok.conf') if os.path.exists(config_file): diff --git a/src/wok/control/config.py b/src/wok/control/config.py index 419abc0..05383c7 100644 --- a/src/wok/control/config.py +++ b/src/wok/control/config.py @@ -17,7 +17,7 @@ # License along with this library; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA -from wok.control.base import Resource +from wok.control.base import Collection, Resource from wok.control.utils import UrlSubNode @@ -28,15 +28,44 @@ CONFIG_REQUESTS = { } +PLUGIN_REQUESTS = { + 'POST': { + 'enable': "WOKPLUGIN0001L", + 'disable': "WOKPLUGIN0002L", + }, +} + + @UrlSubNode("config") class Config(Resource): def __init__(self, model, id=None): super(Config, self).__init__(model, id) self.uri_fmt = '/config/%s' self.admin_methods = ['POST'] + self.plugins = Plugins(self.model) self.log_map = CONFIG_REQUESTS self.reload = self.generate_action_handler('reload') @property def data(self): return self.info + + +class Plugins(Collection): + def __init__(self, model): + super(Plugins, self).__init__(model) + self.resource = Plugin + + +class Plugin(Resource): + def __init__(self, model, ident=None): + super(Plugin, self).__init__(model, ident) + self.ident = ident + self.uri_fmt = "/config/plugins/%s" + self.log_map = PLUGIN_REQUESTS + self.enable = self.generate_action_handler('enable') + self.disable = self.generate_action_handler('disable') + + @property + def data(self): + return self.info diff --git a/src/wok/control/plugins.py b/src/wok/control/plugins.py deleted file mode 100644 index 57dfa1b..0000000 --- a/src/wok/control/plugins.py +++ /dev/null @@ -1,29 +0,0 @@ -# -# Project Wok -# -# Copyright IBM Corp, 2015-2016 -# -# Code derived from Project Kimchi -# -# This library is free software; you can redistribute it and/or -# modify it under the terms of the GNU Lesser General Public -# License as published by the Free Software Foundation; either -# version 2.1 of the License, or (at your option) any later version. -# -# This library 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 -# Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public -# License along with this library; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - -from wok.control.base import SimpleCollection -from wok.control.utils import UrlSubNode - - -@UrlSubNode("plugins") -class Plugins(SimpleCollection): - def __init__(self, model): - super(Plugins, self).__init__(model) diff --git a/src/wok/i18n.py b/src/wok/i18n.py index 935c9c1..d44c2f6 100644 --- a/src/wok/i18n.py +++ b/src/wok/i18n.py @@ -57,6 +57,8 @@ messages = { "WOKCONFIG0001I": _("WoK is going to restart. Existing WoK connections will be closed."), + "WOKPLUGIN0001E": _("Unable to find plug-in %(name)s"), + # These messages (ending with L) are for user log purposes "WOKASYNC0001L": _("Successfully completed task '%(target_uri)s'"), "WOKASYNC0002L": _("Failed to complete task '%(target_uri)s'"), @@ -65,4 +67,6 @@ messages = { "WOKRES0001L": _("Request made on resource"), "WOKROOT0001L": _("User '%(username)s' login"), "WOKROOT0002L": _("User '%(username)s' logout"), + "WOKPLUGIN0001L": _("Enable plug-in %(ident)s."), + "WOKPLUGIN0002L": _("Disable plug-in %(ident)s."), } diff --git a/src/wok/model/plugins.py b/src/wok/model/plugins.py index 1b8ec5e..1b39e6c 100644 --- a/src/wok/model/plugins.py +++ b/src/wok/model/plugins.py @@ -1,7 +1,7 @@ # # Project Wok # -# Copyright IBM Corp, 2015-2016 +# Copyright IBM Corp, 2015-2017 # # Code derived from Project Kimchi # @@ -19,10 +19,11 @@ # License along with this library; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA -import cherrypy -from wok.config import get_base_plugin_uri -from wok.utils import get_enabled_plugins +from wok.exception import NotFoundError +from wok.utils import get_all_affected_plugins_by_plugin +from wok.utils import get_plugin_dependencies, get_plugins, load_plugin_conf +from wok.utils import set_plugin_state class PluginsModel(object): @@ -30,7 +31,30 @@ class PluginsModel(object): pass def get_list(self): - # Will only return plugins that were loaded correctly by WOK and are - # properly configured in cherrypy - return [plugin for (plugin, config) in get_enabled_plugins() - if get_base_plugin_uri(plugin) in cherrypy.tree.apps.keys()] + return [plugin for (plugin, config) in get_plugins()] + + +class PluginModel(object): + def __init__(self, **kargs): + pass + + def lookup(self, name): + name = name.encode('utf-8') + + plugin_conf = load_plugin_conf(name) + if not plugin_conf: + raise NotFoundError("WOKPLUGIN0001E", {'name': name}) + + depends = get_plugin_dependencies(name) + is_dependency_of = get_all_affected_plugins_by_plugin(name) + + return {"name": name, "enabled": plugin_conf['wok']['enable'], + "depends": depends, "is_dependency_of": is_dependency_of} + + def enable(self, name): + name = name.encode('utf-8') + set_plugin_state(name, True) + + def disable(self, name): + name = name.encode('utf-8') + set_plugin_state(name, False) diff --git a/src/wok/server.py b/src/wok/server.py index 48f455b..9b49c1a 100644 --- a/src/wok/server.py +++ b/src/wok/server.py @@ -1,7 +1,7 @@ # # Project Wok # -# Copyright IBM Corp, 2015-2016 +# Copyright IBM Corp, 2015-2017 # # Code derived from Project Kimchi # @@ -28,14 +28,14 @@ import os from wok import auth from wok import config from wok.config import config as configParser -from wok.config import PluginConfig, WokConfig +from wok.config import WokConfig from wok.control import sub_nodes from wok.model import model from wok.proxy import check_proxy_config from wok.reqlogger import RequestLogger from wok.root import WokRoot from wok.safewatchedfilehandler import SafeWatchedFileHandler -from wok.utils import get_enabled_plugins, import_class +from wok.utils import get_enabled_plugins, load_plugin LOGGING_LEVEL = {"debug": logging.DEBUG, @@ -153,56 +153,12 @@ class Server(object): self.app = cherrypy.tree.mount(WokRoot(model_instance, dev_env), options.server_root, self.configObj) - self._load_plugins(options) + self._load_plugins() cherrypy.lib.sessions.init() - def _load_plugins(self, options): + def _load_plugins(self): for plugin_name, plugin_config in get_enabled_plugins(): - try: - plugin_class = ('plugins.%s.%s' % - (plugin_name, - plugin_name[0].upper() + plugin_name[1:])) - del plugin_config['wok'] - plugin_config.update(PluginConfig(plugin_name)) - except KeyError: - continue - - try: - plugin_app = import_class(plugin_class)(options) - except (ImportError, Exception), e: - cherrypy.log.error_log.error( - "Failed to import plugin %s, " - "error: %s" % (plugin_class, e.message) - ) - continue - - # dynamically extend plugin config with custom data, if provided - get_custom_conf = getattr(plugin_app, "get_custom_conf", None) - if get_custom_conf is not None: - plugin_config.update(get_custom_conf()) - - # dynamically add tools.wokauth.on = True to extra plugin APIs - try: - sub_nodes = import_class('plugins.%s.control.sub_nodes' % - plugin_name) - - urlSubNodes = {} - for ident, node in sub_nodes.items(): - if node.url_auth: - ident = "/%s" % ident - urlSubNodes[ident] = {'tools.wokauth.on': True} - - plugin_config.update(urlSubNodes) - - except ImportError, e: - cherrypy.log.error_log.error( - "Failed to import subnodes for plugin %s, " - "error: %s" % (plugin_class, e.message) - ) - - cherrypy.tree.mount(plugin_app, - config.get_base_plugin_uri(plugin_name), - plugin_config) + load_plugin(plugin_name, plugin_config) def start(self): # Subscribe to SignalHandler plugin diff --git a/src/wok/utils.py b/src/wok/utils.py index 9a08001..9e6bb8a 100644 --- a/src/wok/utils.py +++ b/src/wok/utils.py @@ -1,7 +1,7 @@ # # Project Wok # -# Copyright IBM Corp, 2015-2016 +# Copyright IBM Corp, 2015-2017 # # Code derived from Project Kimchi # @@ -37,9 +37,11 @@ import xml.etree.ElementTree as ET from cherrypy.lib.reprconf import Parser from datetime import datetime, timedelta from multiprocessing import Process, Queue +from optparse import Values from threading import Timer -from wok.config import paths, PluginPaths +from wok import config +from wok.config import paths, PluginConfig, PluginPaths from wok.exception import InvalidParameter, TimeoutExpired from wok.stringutils import decode_value @@ -57,13 +59,21 @@ def is_digit(value): return False -def _load_plugin_conf(name): +def get_plugin_config_file(name): plugin_conf = PluginPaths(name).conf_file if not os.path.exists(plugin_conf): cherrypy.log.error_log.error("Plugin configuration file %s" " doesn't exist." % plugin_conf) - return + return None + return plugin_conf + + +def load_plugin_conf(name): try: + plugin_conf = get_plugin_config_file(name) + if not plugin_conf: + return None + return Parser().dict_from_file(plugin_conf) except ValueError as e: cherrypy.log.error_log.error("Failed to load plugin " @@ -71,22 +81,223 @@ def _load_plugin_conf(name): (plugin_conf, e.message)) -def get_enabled_plugins(): +def get_plugins(enabled_only=False): plugin_dir = paths.plugins_dir + try: dir_contents = os.listdir(plugin_dir) except OSError: return + + test_mode = config.config.get('server', 'test').lower() == 'true' + for name in dir_contents: if os.path.isdir(os.path.join(plugin_dir, name)): - plugin_config = _load_plugin_conf(name) + if name == 'sample' and not test_mode: + continue + + plugin_config = load_plugin_conf(name) + if not plugin_config: + continue try: - if plugin_config['wok']['enable']: - yield (name, plugin_config) + if plugin_config['wok']['enable'] is None: + continue + + plugin_enabled = plugin_config['wok']['enable'] + if enabled_only and not plugin_enabled: + continue + + yield (name, plugin_config) except (TypeError, KeyError): continue +def get_enabled_plugins(): + return get_plugins(enabled_only=True) + + +def get_plugin_app_mounted_in_cherrypy(name): + plugin_uri = '/plugins/' + name + return cherrypy.tree.apps.get(plugin_uri, None) + + +def get_plugin_dependencies(name): + app = get_plugin_app_mounted_in_cherrypy(name) + if app is None or not hasattr(app.root, 'depends'): + return [] + return app.root.depends + + +def get_all_plugins_dependent_on(name): + if not cherrypy.tree.apps: + return [] + + dependencies = [] + for plugin, app in cherrypy.tree.apps.iteritems(): + if hasattr(app.root, 'depends') and name in app.root.depends: + dependencies.append(plugin.replace('/plugins/', '')) + + return dependencies + + +def get_all_affected_plugins_by_plugin(name): + dependencies = get_all_plugins_dependent_on(name) + if len(dependencies) == 0: + return [] + + all_affected_plugins = dependencies + for dep in dependencies: + all_affected_plugins += get_all_affected_plugins_by_plugin(dep) + + return all_affected_plugins + + +def disable_plugin(name): + plugin_deps = get_all_affected_plugins_by_plugin(name) + + for dep in set(plugin_deps): + update_plugin_config_file(dep, False) + update_cherrypy_mounted_tree(dep, False) + + update_plugin_config_file(name, False) + update_cherrypy_mounted_tree(name, False) + + +def enable_plugin(name): + update_plugin_config_file(name, True) + update_cherrypy_mounted_tree(name, True) + + plugin_deps = get_plugin_dependencies(name) + + for dep in set(plugin_deps): + enable_plugin(dep) + + +def set_plugin_state(name, state): + if state is False: + disable_plugin(name) + else: + enable_plugin(name) + + +def update_plugin_config_file(name, state): + plugin_conf = get_plugin_config_file(name) + if not plugin_conf: + return + + config_contents = None + + with open(plugin_conf, 'r') as f: + config_contents = f.readlines() + + wok_section_found = False + + pattern = re.compile("^\s*enable\s*=\s*") + + for i in range(0, len(config_contents)): + if config_contents[i] == '[wok]\n': + wok_section_found = True + continue + + if pattern.match(config_contents[i]) and wok_section_found: + config_contents[i] = 'enable = %s\n' % str(state) + break + + with open(plugin_conf, 'w') as f: + f.writelines(config_contents) + + +def load_plugin(plugin_name, plugin_config): + try: + plugin_class = ('plugins.%s.%s' % + (plugin_name, + plugin_name[0].upper() + plugin_name[1:])) + del plugin_config['wok'] + plugin_config.update(PluginConfig(plugin_name)) + except KeyError: + return + + try: + options = get_plugin_config_options() + plugin_app = import_class(plugin_class)(options) + except (ImportError, Exception), e: + cherrypy.log.error_log.error( + "Failed to import plugin %s, " + "error: %s" % (plugin_class, e.message) + ) + return + + # dynamically extend plugin config with custom data, if provided + get_custom_conf = getattr(plugin_app, "get_custom_conf", None) + if get_custom_conf is not None: + plugin_config.update(get_custom_conf()) + + # dynamically add tools.wokauth.on = True to extra plugin APIs + try: + sub_nodes = import_class('plugins.%s.control.sub_nodes' % + plugin_name) + + urlSubNodes = {} + for ident, node in sub_nodes.items(): + if node.url_auth: + ident = "/%s" % ident + urlSubNodes[ident] = {'tools.wokauth.on': True} + + plugin_config.update(urlSubNodes) + + except ImportError, e: + cherrypy.log.error_log.error( + "Failed to import subnodes for plugin %s, " + "error: %s" % (plugin_class, e.message) + ) + + cherrypy.tree.mount(plugin_app, + config.get_base_plugin_uri(plugin_name), + plugin_config) + + +def is_plugin_mounted_in_cherrypy(plugin_uri): + return cherrypy.tree.apps.get(plugin_uri) is not None + + +def update_cherrypy_mounted_tree(plugin, state): + plugin_uri = '/plugin/' + plugin + + if state is False and is_plugin_mounted_in_cherrypy(plugin_uri): + del cherrypy.tree.apps[plugin_uri] + + if state is True and not is_plugin_mounted_in_cherrypy(plugin_uri): + plugin_config = load_plugin_conf(plugin) + load_plugin(plugin, plugin_config) + + +def get_plugin_config_options(): + options = Values() + + options.websockets_port = config.config.getint('server', + 'websockets_port') + options.cherrypy_port = config.config.getint('server', + 'cherrypy_port') + options.proxy_port = config.config.getint('server', 'proxy_port') + options.session_timeout = config.config.getint('server', + 'session_timeout') + + options.test = config.config.get('server', 'test') + if options.test == 'None': + options.test = None + + options.environment = config.config.get('server', 'environment') + options.server_root = config.config.get('server', 'server_root') + options.max_body_size = config.config.get('server', 'max_body_size') + + options.log_dir = config.config.get('logging', 'log_dir') + options.log_level = config.config.get('logging', 'log_level') + options.access_log = config.config.get('logging', 'access_log') + options.error_log = config.config.get('logging', 'error_log') + + return options + + def get_all_tabs(): files = [os.path.join(paths.ui_dir, 'config/tab-ext.xml')] diff --git a/tests/test_api.py b/tests/test_api.py index 1430bc1..6fbee75 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -26,6 +26,8 @@ import utils from functools import partial from wok.asynctask import AsyncTask +from wok.utils import set_plugin_state +from wok.rollbackcontext import RollbackContext test_server = None model = None @@ -54,6 +56,63 @@ class APITests(unittest.TestCase): "server_root"] self.assertEquals(sorted(keys), sorted(conf.keys())) + def test_config_plugins(self): + resp = self.request('/config/plugins') + self.assertEquals(200, resp.status) + + plugins = json.loads(resp.read()) + if len(plugins) == 0: + return + + plugin_name = '' + plugin_state = '' + for p in plugins: + if p.get('name') == 'sample': + plugin_name = p.get('name').encode('utf-8') + plugin_state = p.get('enabled') + break + else: + return + + with RollbackContext() as rollback: + rollback.prependDefer(set_plugin_state, plugin_name, + plugin_state) + + resp = self.request('/config/plugins/sample') + self.assertEquals(200, resp.status) + + resp = self.request('/config/plugins/sample/enable', + '{}', 'POST') + self.assertEquals(200, resp.status) + + resp = self.request('/config/plugins') + self.assertEquals(200, resp.status) + plugins = json.loads(resp.read()) + + for p in plugins: + if p.get('name') == 'sample': + plugin_state = p.get('enabled') + break + self.assertTrue(plugin_state) + + resp = self.request('/config/plugins/sample/disable', + '{}', 'POST') + self.assertEquals(200, resp.status) + + resp = self.request('/config/plugins') + self.assertEquals(200, resp.status) + plugins = json.loads(resp.read()) + + for p in plugins: + if p.get('name') == 'sample': + plugin_state = p.get('enabled') + break + self.assertFalse(plugin_state) + + def test_plugins_api_404(self): + resp = self.request('/plugins') + self.assertEquals(404, resp.status) + def test_user_log(self): # Login and logout to make sure there there are entries in user log hdrs = {'AUTHORIZATION': '', diff --git a/tests/test_utils.py b/tests/test_utils.py index e7fd264..e63e1a2 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -19,10 +19,14 @@ # License along with this library; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +import mock +import os +import tempfile import unittest from wok.exception import InvalidParameter -from wok.utils import convert_data_size +from wok.rollbackcontext import RollbackContext +from wok.utils import convert_data_size, set_plugin_state class UtilsTests(unittest.TestCase): @@ -69,3 +73,72 @@ class UtilsTests(unittest.TestCase): for d in success_data: self.assertEquals(d['got'], d['want']) + + def _get_fake_config_file_content(self, enable=True): + return """\ +[a_random_section] +# a random section for testing purposes +enable = 1 + +[wok] +# Enable plugin on Wok server (values: True|False) +enable = %s + +[fakeplugin] +# Yet another comment on this config file +enable = 2 +very_interesting_option = True +""" % str(enable) + + def _get_config_file_template(self, enable=True): + return """\ +[a_random_section] +# a random section for testing purposes +enable = 1 + +[wok] +# Enable plugin on Wok server (values: True|False) +enable = %s + +[fakeplugin] +# Yet another comment on this config file +enable = 2 +very_interesting_option = True +""" % str(enable) + + def _create_fake_config_file(self): + _, tmp_file_name = tempfile.mkstemp(suffix='.conf') + + config_contents = self._get_fake_config_file_content() + with open(tmp_file_name, 'w') as f: + f.writelines(config_contents) + + return tmp_file_name + + @mock.patch('wok.utils.get_plugin_config_file') + @mock.patch('wok.utils.update_cherrypy_mounted_tree') + def test_set_plugin_state(self, mock_update_cherrypy, mock_config_file): + mock_update_cherrypy.return_value = True + + with RollbackContext() as rollback: + + config_file_name = self._create_fake_config_file() + rollback.prependDefer(os.remove, config_file_name) + + mock_config_file.return_value = config_file_name + + set_plugin_state('pluginA', False) + with open(config_file_name, 'r') as f: + updated_conf = f.read() + self.assertEqual( + updated_conf, + self._get_config_file_template(enable=False) + ) + + set_plugin_state('pluginA', True) + with open(config_file_name, 'r') as f: + updated_conf = f.read() + self.assertEqual( + updated_conf, + self._get_config_file_template(enable=True) + ) -- 2.9.3

Hi Daniel, On 02/01/2017 10:03 AM, dhbarboza82@gmail.com wrote:
From: Daniel Henrique Barboza <danielhb@linux.vnet.ibm.com>
This patch adds a backend for a new API called /config/plugins.
The idea is to be able to retrieve the 'enable' status of WoK plug-ins and also provide a way to enable/disable them. The enable|disable operation consists on two steps:
- changing the 'enable=' attribute of the [WoK] section of the plugin .conf file;
- the plug-in is removed/added in the cherrypy.tree on the fly.
Several changes/enhancements in the backend were made to make this possible, such as:
- added the 'test' parameter in the config.py.in file to make it available for reading in the backend. This parameter indicates whether WoK is running in test mode;
- 'load_plugin' was moved from server.py to utils.py to make it available for utils functions to load plug-ins;
- a new 'depends' attribute is now being considered in the root class of each plug-in. This is an array that indicates all the plug-ins it has a dependency on. For example, Kimchi would mark self.depends = ['gingerbase'] in its root file. The absence of this attribute means that the plug-in does not have any dependency aside from WoK.
Previous /plugins API were removed because it was redundant with this work.
Uni tests included.
Signed-off-by: Daniel Henrique Barboza <danielhb@linux.vnet.ibm.com> --- docs/API/config.md | 32 +++++++ docs/API/plugins.md | 13 --- src/wok/config.py.in | 5 +- src/wok/control/config.py | 31 ++++++- src/wok/control/plugins.py | 29 ------ src/wok/i18n.py | 4 + src/wok/model/plugins.py | 40 ++++++-- src/wok/server.py | 56 ++--------- src/wok/utils.py | 227 +++++++++++++++++++++++++++++++++++++++++++-- tests/test_api.py | 59 ++++++++++++ tests/test_utils.py | 75 ++++++++++++++- 11 files changed, 460 insertions(+), 111 deletions(-) delete mode 100644 docs/API/plugins.md delete mode 100644 src/wok/control/plugins.py
diff --git a/docs/API/config.md b/docs/API/config.md index 4ba455e..87619ac 100644 --- a/docs/API/config.md +++ b/docs/API/config.md @@ -26,3 +26,35 @@ GET /config websockets_port: 64667, version: 2.0 } + +### Collection: Plugins + +**URI:** /config/plugins + +**Methods:** + +* **GET**: Retrieve a summarized list of all UI Plugins. + +#### Examples +GET /plugins +[{'name': 'pluginA', 'enabled': True, "depends":['pluginB'], "is_dependency_of":[]}, + {'name': 'pluginB', 'enabled': False, "depends":[], "is_dependency_of":['pluginA']}] + +### Resource: Plugins + +**URI:** /config/plugins/*:name* + +Represents the current state of a given WoK plug-in. + +**Methods:** + +* **GET**: Retrieve the state of the plug-in. + * name: The name of the plug-in. + * enabled: True if the plug-in is currently enabled in WoK, False otherwise. +
You forgot to add the description to depends and is_dependency_of parameters
+* **POST**: *See Plugin Actions* + +**Actions (POST):** + +* enable: Enable the plug-in in the configuration file. +* disable: Disable the plug-in in the configuration file.
As you are now doing the change on the fly, I'd say to add it to the description action as well.
diff --git a/docs/API/plugins.md b/docs/API/plugins.md deleted file mode 100644 index aaa37b5..0000000 --- a/docs/API/plugins.md +++ /dev/null @@ -1,13 +0,0 @@ -## REST API Specification for Plugins - -### Collection: Plugins - -**URI:** /plugins - -**Methods:** - -* **GET**: Retrieve a summarized list names of all UI Plugins - -#### Examples -GET /plugins -[pluginA, pluginB, pluginC] diff --git a/src/wok/config.py.in b/src/wok/config.py.in index 9573e66..0e46b17 100644 --- a/src/wok/config.py.in +++ b/src/wok/config.py.in @@ -1,7 +1,7 @@ # # Project Wok # -# Copyright IBM Corp, 2015-2016 +# Copyright IBM Corp, 2015-2017 # # Code derived from Project Kimchi # @@ -269,6 +269,7 @@ def _get_config(): config.set("server", "environment", "production") config.set('server', 'max_body_size', '4*1024*1024') config.set("server", "server_root", "")
+ config.set("server", "test", "true")
The default value should be 'false' as by default Wok runs on production mode.
config.add_section("authentication") config.set("authentication", "method", "pam") config.set("authentication", "ldap_server", "") @@ -278,6 +279,8 @@ def _get_config(): config.add_section("logging") config.set("logging", "log_dir", paths.log_dir) config.set("logging", "log_level", DEFAULT_LOG_LEVEL)
+ config.set("logging", "access_log", "") + config.set("logging", "error_log", "")
Seems a rebase issue here. There was a patch to remove those configuration (access_log and error_log)
config_file = os.path.join(paths.conf_dir, 'wok.conf') if os.path.exists(config_file): diff --git a/src/wok/control/config.py b/src/wok/control/config.py index 419abc0..05383c7 100644 --- a/src/wok/control/config.py +++ b/src/wok/control/config.py @@ -17,7 +17,7 @@ # License along with this library; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
-from wok.control.base import Resource +from wok.control.base import Collection, Resource from wok.control.utils import UrlSubNode
@@ -28,15 +28,44 @@ CONFIG_REQUESTS = { }
+PLUGIN_REQUESTS = { + 'POST': { + 'enable': "WOKPLUGIN0001L", + 'disable': "WOKPLUGIN0002L", + }, +} + + @UrlSubNode("config") class Config(Resource): def __init__(self, model, id=None): super(Config, self).__init__(model, id) self.uri_fmt = '/config/%s' self.admin_methods = ['POST'] + self.plugins = Plugins(self.model) self.log_map = CONFIG_REQUESTS self.reload = self.generate_action_handler('reload')
@property def data(self): return self.info + + +class Plugins(Collection): + def __init__(self, model): + super(Plugins, self).__init__(model) + self.resource = Plugin + + +class Plugin(Resource): + def __init__(self, model, ident=None): + super(Plugin, self).__init__(model, ident) + self.ident = ident + self.uri_fmt = "/config/plugins/%s" + self.log_map = PLUGIN_REQUESTS + self.enable = self.generate_action_handler('enable') + self.disable = self.generate_action_handler('disable') +
Please, set self.admin_methods = [POST] to restrict enable/disable operations to admin users. Also update test_authorization.py to validate that. Use the sample plugin in the tests.
+ @property + def data(self): + return self.info diff --git a/src/wok/control/plugins.py b/src/wok/control/plugins.py deleted file mode 100644 index 57dfa1b..0000000 --- a/src/wok/control/plugins.py +++ /dev/null @@ -1,29 +0,0 @@ -# -# Project Wok -# -# Copyright IBM Corp, 2015-2016 -# -# Code derived from Project Kimchi -# -# This library is free software; you can redistribute it and/or -# modify it under the terms of the GNU Lesser General Public -# License as published by the Free Software Foundation; either -# version 2.1 of the License, or (at your option) any later version. -# -# This library 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 -# Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public -# License along with this library; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - -from wok.control.base import SimpleCollection -from wok.control.utils import UrlSubNode - - -@UrlSubNode("plugins") -class Plugins(SimpleCollection): - def __init__(self, model): - super(Plugins, self).__init__(model) diff --git a/src/wok/i18n.py b/src/wok/i18n.py index 935c9c1..d44c2f6 100644 --- a/src/wok/i18n.py +++ b/src/wok/i18n.py @@ -57,6 +57,8 @@ messages = {
"WOKCONFIG0001I": _("WoK is going to restart. Existing WoK connections will be closed."),
+ "WOKPLUGIN0001E": _("Unable to find plug-in %(name)s"), + # These messages (ending with L) are for user log purposes "WOKASYNC0001L": _("Successfully completed task '%(target_uri)s'"), "WOKASYNC0002L": _("Failed to complete task '%(target_uri)s'"), @@ -65,4 +67,6 @@ messages = { "WOKRES0001L": _("Request made on resource"), "WOKROOT0001L": _("User '%(username)s' login"), "WOKROOT0002L": _("User '%(username)s' logout"), + "WOKPLUGIN0001L": _("Enable plug-in %(ident)s."), + "WOKPLUGIN0002L": _("Disable plug-in %(ident)s."), } diff --git a/src/wok/model/plugins.py b/src/wok/model/plugins.py index 1b8ec5e..1b39e6c 100644 --- a/src/wok/model/plugins.py +++ b/src/wok/model/plugins.py @@ -1,7 +1,7 @@ # # Project Wok # -# Copyright IBM Corp, 2015-2016 +# Copyright IBM Corp, 2015-2017 # # Code derived from Project Kimchi # @@ -19,10 +19,11 @@ # License along with this library; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
-import cherrypy
-from wok.config import get_base_plugin_uri -from wok.utils import get_enabled_plugins +from wok.exception import NotFoundError +from wok.utils import get_all_affected_plugins_by_plugin +from wok.utils import get_plugin_dependencies, get_plugins, load_plugin_conf +from wok.utils import set_plugin_state
class PluginsModel(object): @@ -30,7 +31,30 @@ class PluginsModel(object): pass
def get_list(self): - # Will only return plugins that were loaded correctly by WOK and are - # properly configured in cherrypy - return [plugin for (plugin, config) in get_enabled_plugins() - if get_base_plugin_uri(plugin) in cherrypy.tree.apps.keys()] + return [plugin for (plugin, config) in get_plugins()] + + +class PluginModel(object): + def __init__(self, **kargs): + pass + + def lookup(self, name): + name = name.encode('utf-8') + + plugin_conf = load_plugin_conf(name) + if not plugin_conf: + raise NotFoundError("WOKPLUGIN0001E", {'name': name}) + + depends = get_plugin_dependencies(name) + is_dependency_of = get_all_affected_plugins_by_plugin(name) + + return {"name": name, "enabled": plugin_conf['wok']['enable'], + "depends": depends, "is_dependency_of": is_dependency_of} + + def enable(self, name): + name = name.encode('utf-8') + set_plugin_state(name, True) + + def disable(self, name): + name = name.encode('utf-8') + set_plugin_state(name, False) diff --git a/src/wok/server.py b/src/wok/server.py index 48f455b..9b49c1a 100644 --- a/src/wok/server.py +++ b/src/wok/server.py @@ -1,7 +1,7 @@ # # Project Wok # -# Copyright IBM Corp, 2015-2016 +# Copyright IBM Corp, 2015-2017 # # Code derived from Project Kimchi # @@ -28,14 +28,14 @@ import os from wok import auth from wok import config from wok.config import config as configParser -from wok.config import PluginConfig, WokConfig +from wok.config import WokConfig from wok.control import sub_nodes from wok.model import model from wok.proxy import check_proxy_config from wok.reqlogger import RequestLogger from wok.root import WokRoot from wok.safewatchedfilehandler import SafeWatchedFileHandler -from wok.utils import get_enabled_plugins, import_class +from wok.utils import get_enabled_plugins, load_plugin
LOGGING_LEVEL = {"debug": logging.DEBUG, @@ -153,56 +153,12 @@ class Server(object): self.app = cherrypy.tree.mount(WokRoot(model_instance, dev_env), options.server_root, self.configObj)
- self._load_plugins(options) + self._load_plugins() cherrypy.lib.sessions.init()
- def _load_plugins(self, options): + def _load_plugins(self): for plugin_name, plugin_config in get_enabled_plugins(): - try: - plugin_class = ('plugins.%s.%s' % - (plugin_name, - plugin_name[0].upper() + plugin_name[1:])) - del plugin_config['wok'] - plugin_config.update(PluginConfig(plugin_name)) - except KeyError: - continue - - try: - plugin_app = import_class(plugin_class)(options) - except (ImportError, Exception), e: - cherrypy.log.error_log.error( - "Failed to import plugin %s, " - "error: %s" % (plugin_class, e.message) - ) - continue - - # dynamically extend plugin config with custom data, if provided - get_custom_conf = getattr(plugin_app, "get_custom_conf", None) - if get_custom_conf is not None: - plugin_config.update(get_custom_conf()) - - # dynamically add tools.wokauth.on = True to extra plugin APIs - try: - sub_nodes = import_class('plugins.%s.control.sub_nodes' % - plugin_name) - - urlSubNodes = {} - for ident, node in sub_nodes.items(): - if node.url_auth: - ident = "/%s" % ident - urlSubNodes[ident] = {'tools.wokauth.on': True} - - plugin_config.update(urlSubNodes) - - except ImportError, e: - cherrypy.log.error_log.error( - "Failed to import subnodes for plugin %s, " - "error: %s" % (plugin_class, e.message) - ) - - cherrypy.tree.mount(plugin_app, - config.get_base_plugin_uri(plugin_name), - plugin_config) + load_plugin(plugin_name, plugin_config)
def start(self): # Subscribe to SignalHandler plugin diff --git a/src/wok/utils.py b/src/wok/utils.py index 9a08001..9e6bb8a 100644 --- a/src/wok/utils.py +++ b/src/wok/utils.py @@ -1,7 +1,7 @@ # # Project Wok # -# Copyright IBM Corp, 2015-2016 +# Copyright IBM Corp, 2015-2017 # # Code derived from Project Kimchi # @@ -37,9 +37,11 @@ import xml.etree.ElementTree as ET from cherrypy.lib.reprconf import Parser from datetime import datetime, timedelta from multiprocessing import Process, Queue +from optparse import Values from threading import Timer
-from wok.config import paths, PluginPaths +from wok import config +from wok.config import paths, PluginConfig, PluginPaths from wok.exception import InvalidParameter, TimeoutExpired from wok.stringutils import decode_value
@@ -57,13 +59,21 @@ def is_digit(value): return False
-def _load_plugin_conf(name): +def get_plugin_config_file(name): plugin_conf = PluginPaths(name).conf_file if not os.path.exists(plugin_conf): cherrypy.log.error_log.error("Plugin configuration file %s" " doesn't exist." % plugin_conf) - return + return None + return plugin_conf + + +def load_plugin_conf(name): try: + plugin_conf = get_plugin_config_file(name) + if not plugin_conf: + return None + return Parser().dict_from_file(plugin_conf) except ValueError as e: cherrypy.log.error_log.error("Failed to load plugin " @@ -71,22 +81,223 @@ def _load_plugin_conf(name): (plugin_conf, e.message))
-def get_enabled_plugins(): +def get_plugins(enabled_only=False): plugin_dir = paths.plugins_dir + try: dir_contents = os.listdir(plugin_dir) except OSError: return + + test_mode = config.config.get('server', 'test').lower() == 'true' + for name in dir_contents: if os.path.isdir(os.path.join(plugin_dir, name)): - plugin_config = _load_plugin_conf(name) + if name == 'sample' and not test_mode: + continue + + plugin_config = load_plugin_conf(name) + if not plugin_config: + continue try: - if plugin_config['wok']['enable']: - yield (name, plugin_config) + if plugin_config['wok']['enable'] is None: + continue + + plugin_enabled = plugin_config['wok']['enable'] + if enabled_only and not plugin_enabled: + continue + + yield (name, plugin_config) except (TypeError, KeyError): continue
+def get_enabled_plugins(): + return get_plugins(enabled_only=True) + + +def get_plugin_app_mounted_in_cherrypy(name): + plugin_uri = '/plugins/' + name + return cherrypy.tree.apps.get(plugin_uri, None) + + +def get_plugin_dependencies(name): + app = get_plugin_app_mounted_in_cherrypy(name) + if app is None or not hasattr(app.root, 'depends'): + return [] + return app.root.depends + + +def get_all_plugins_dependent_on(name): + if not cherrypy.tree.apps: + return [] + + dependencies = [] + for plugin, app in cherrypy.tree.apps.iteritems(): + if hasattr(app.root, 'depends') and name in app.root.depends: + dependencies.append(plugin.replace('/plugins/', '')) + + return dependencies + + +def get_all_affected_plugins_by_plugin(name): + dependencies = get_all_plugins_dependent_on(name) + if len(dependencies) == 0: + return [] + + all_affected_plugins = dependencies + for dep in dependencies: + all_affected_plugins += get_all_affected_plugins_by_plugin(dep) + + return all_affected_plugins + + +def disable_plugin(name): + plugin_deps = get_all_affected_plugins_by_plugin(name) + + for dep in set(plugin_deps): + update_plugin_config_file(dep, False) + update_cherrypy_mounted_tree(dep, False) + + update_plugin_config_file(name, False) + update_cherrypy_mounted_tree(name, False) + + +def enable_plugin(name): + update_plugin_config_file(name, True) + update_cherrypy_mounted_tree(name, True) + + plugin_deps = get_plugin_dependencies(name) + + for dep in set(plugin_deps): + enable_plugin(dep) + + +def set_plugin_state(name, state): + if state is False: + disable_plugin(name) + else: + enable_plugin(name) + + +def update_plugin_config_file(name, state): + plugin_conf = get_plugin_config_file(name) + if not plugin_conf: + return + + config_contents = None + + with open(plugin_conf, 'r') as f: + config_contents = f.readlines() + + wok_section_found = False + + pattern = re.compile("^\s*enable\s*=\s*") + + for i in range(0, len(config_contents)): + if config_contents[i] == '[wok]\n': + wok_section_found = True + continue + + if pattern.match(config_contents[i]) and wok_section_found: + config_contents[i] = 'enable = %s\n' % str(state) + break + + with open(plugin_conf, 'w') as f: + f.writelines(config_contents) + + +def load_plugin(plugin_name, plugin_config): + try: + plugin_class = ('plugins.%s.%s' % + (plugin_name, + plugin_name[0].upper() + plugin_name[1:])) + del plugin_config['wok'] + plugin_config.update(PluginConfig(plugin_name)) + except KeyError: + return + + try: + options = get_plugin_config_options() + plugin_app = import_class(plugin_class)(options) + except (ImportError, Exception), e: + cherrypy.log.error_log.error( + "Failed to import plugin %s, " + "error: %s" % (plugin_class, e.message) + ) + return + + # dynamically extend plugin config with custom data, if provided + get_custom_conf = getattr(plugin_app, "get_custom_conf", None) + if get_custom_conf is not None: + plugin_config.update(get_custom_conf()) + + # dynamically add tools.wokauth.on = True to extra plugin APIs + try: + sub_nodes = import_class('plugins.%s.control.sub_nodes' % + plugin_name) + + urlSubNodes = {} + for ident, node in sub_nodes.items(): + if node.url_auth: + ident = "/%s" % ident + urlSubNodes[ident] = {'tools.wokauth.on': True} + + plugin_config.update(urlSubNodes) + + except ImportError, e: + cherrypy.log.error_log.error( + "Failed to import subnodes for plugin %s, " + "error: %s" % (plugin_class, e.message) + ) + + cherrypy.tree.mount(plugin_app, + config.get_base_plugin_uri(plugin_name), + plugin_config) + + +def is_plugin_mounted_in_cherrypy(plugin_uri): + return cherrypy.tree.apps.get(plugin_uri) is not None + + +def update_cherrypy_mounted_tree(plugin, state): + plugin_uri = '/plugin/' + plugin + + if state is False and is_plugin_mounted_in_cherrypy(plugin_uri): + del cherrypy.tree.apps[plugin_uri] + + if state is True and not is_plugin_mounted_in_cherrypy(plugin_uri): + plugin_config = load_plugin_conf(plugin) + load_plugin(plugin, plugin_config) + + +def get_plugin_config_options(): + options = Values() + + options.websockets_port = config.config.getint('server', + 'websockets_port') + options.cherrypy_port = config.config.getint('server', + 'cherrypy_port') + options.proxy_port = config.config.getint('server', 'proxy_port') + options.session_timeout = config.config.getint('server', + 'session_timeout') + + options.test = config.config.get('server', 'test') + if options.test == 'None': + options.test = None + + options.environment = config.config.get('server', 'environment') + options.server_root = config.config.get('server', 'server_root') + options.max_body_size = config.config.get('server', 'max_body_size') + + options.log_dir = config.config.get('logging', 'log_dir') + options.log_level = config.config.get('logging', 'log_level') + options.access_log = config.config.get('logging', 'access_log') + options.error_log = config.config.get('logging', 'error_log') + + return options + + def get_all_tabs(): files = [os.path.join(paths.ui_dir, 'config/tab-ext.xml')]
diff --git a/tests/test_api.py b/tests/test_api.py index 1430bc1..6fbee75 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -26,6 +26,8 @@ import utils from functools import partial
from wok.asynctask import AsyncTask +from wok.utils import set_plugin_state +from wok.rollbackcontext import RollbackContext
test_server = None model = None @@ -54,6 +56,63 @@ class APITests(unittest.TestCase): "server_root"] self.assertEquals(sorted(keys), sorted(conf.keys()))
+ def test_config_plugins(self): + resp = self.request('/config/plugins') + self.assertEquals(200, resp.status) + + plugins = json.loads(resp.read()) + if len(plugins) == 0: + return + + plugin_name = '' + plugin_state = '' + for p in plugins: + if p.get('name') == 'sample': + plugin_name = p.get('name').encode('utf-8') + plugin_state = p.get('enabled') + break + else: + return + + with RollbackContext() as rollback: + rollback.prependDefer(set_plugin_state, plugin_name, + plugin_state) + + resp = self.request('/config/plugins/sample') + self.assertEquals(200, resp.status) + + resp = self.request('/config/plugins/sample/enable', + '{}', 'POST') + self.assertEquals(200, resp.status) + + resp = self.request('/config/plugins') + self.assertEquals(200, resp.status) + plugins = json.loads(resp.read()) + + for p in plugins: + if p.get('name') == 'sample': + plugin_state = p.get('enabled') + break + self.assertTrue(plugin_state) + + resp = self.request('/config/plugins/sample/disable', + '{}', 'POST') + self.assertEquals(200, resp.status) + + resp = self.request('/config/plugins') + self.assertEquals(200, resp.status) + plugins = json.loads(resp.read()) + + for p in plugins: + if p.get('name') == 'sample': + plugin_state = p.get('enabled') + break + self.assertFalse(plugin_state) + + def test_plugins_api_404(self): + resp = self.request('/plugins') + self.assertEquals(404, resp.status) + def test_user_log(self): # Login and logout to make sure there there are entries in user log hdrs = {'AUTHORIZATION': '', diff --git a/tests/test_utils.py b/tests/test_utils.py index e7fd264..e63e1a2 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -19,10 +19,14 @@ # License along with this library; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+import mock +import os +import tempfile import unittest
from wok.exception import InvalidParameter -from wok.utils import convert_data_size +from wok.rollbackcontext import RollbackContext +from wok.utils import convert_data_size, set_plugin_state
class UtilsTests(unittest.TestCase): @@ -69,3 +73,72 @@ class UtilsTests(unittest.TestCase):
for d in success_data: self.assertEquals(d['got'], d['want']) + + def _get_fake_config_file_content(self, enable=True): + return """\ +[a_random_section] +# a random section for testing purposes +enable = 1 + +[wok] +# Enable plugin on Wok server (values: True|False) +enable = %s + +[fakeplugin] +# Yet another comment on this config file +enable = 2 +very_interesting_option = True +""" % str(enable) + + def _get_config_file_template(self, enable=True): + return """\ +[a_random_section] +# a random section for testing purposes +enable = 1 + +[wok] +# Enable plugin on Wok server (values: True|False) +enable = %s + +[fakeplugin] +# Yet another comment on this config file +enable = 2 +very_interesting_option = True +""" % str(enable) + + def _create_fake_config_file(self): + _, tmp_file_name = tempfile.mkstemp(suffix='.conf') + + config_contents = self._get_fake_config_file_content() + with open(tmp_file_name, 'w') as f: + f.writelines(config_contents) + + return tmp_file_name + + @mock.patch('wok.utils.get_plugin_config_file') + @mock.patch('wok.utils.update_cherrypy_mounted_tree') + def test_set_plugin_state(self, mock_update_cherrypy, mock_config_file): + mock_update_cherrypy.return_value = True + + with RollbackContext() as rollback: + + config_file_name = self._create_fake_config_file() + rollback.prependDefer(os.remove, config_file_name) + + mock_config_file.return_value = config_file_name + + set_plugin_state('pluginA', False) + with open(config_file_name, 'r') as f: + updated_conf = f.read() + self.assertEqual( + updated_conf, + self._get_config_file_template(enable=False) + ) + + set_plugin_state('pluginA', True) + with open(config_file_name, 'r') as f: + updated_conf = f.read() + self.assertEqual( + updated_conf, + self._get_config_file_template(enable=True) + )

On 02/03/2017 12:21 PM, Aline Manera wrote:
Hi Daniel,
On 02/01/2017 10:03 AM, dhbarboza82@gmail.com wrote:
From: Daniel Henrique Barboza <danielhb@linux.vnet.ibm.com>
This patch adds a backend for a new API called /config/plugins.
The idea is to be able to retrieve the 'enable' status of WoK plug-ins and also provide a way to enable/disable them. The enable|disable operation consists on two steps:
- changing the 'enable=' attribute of the [WoK] section of the plugin .conf file;
- the plug-in is removed/added in the cherrypy.tree on the fly.
Several changes/enhancements in the backend were made to make this possible, such as:
- added the 'test' parameter in the config.py.in file to make it available for reading in the backend. This parameter indicates whether WoK is running in test mode;
- 'load_plugin' was moved from server.py to utils.py to make it available for utils functions to load plug-ins;
- a new 'depends' attribute is now being considered in the root class of each plug-in. This is an array that indicates all the plug-ins it has a dependency on. For example, Kimchi would mark self.depends = ['gingerbase'] in its root file. The absence of this attribute means that the plug-in does not have any dependency aside from WoK.
Previous /plugins API were removed because it was redundant with this work.
Uni tests included.
Signed-off-by: Daniel Henrique Barboza <danielhb@linux.vnet.ibm.com> --- docs/API/config.md | 32 +++++++ docs/API/plugins.md | 13 --- src/wok/config.py.in | 5 +- src/wok/control/config.py | 31 ++++++- src/wok/control/plugins.py | 29 ------ src/wok/i18n.py | 4 + src/wok/model/plugins.py | 40 ++++++-- src/wok/server.py | 56 ++--------- src/wok/utils.py | 227 +++++++++++++++++++++++++++++++++++++++++++-- tests/test_api.py | 59 ++++++++++++ tests/test_utils.py | 75 ++++++++++++++- 11 files changed, 460 insertions(+), 111 deletions(-) delete mode 100644 docs/API/plugins.md delete mode 100644 src/wok/control/plugins.py
diff --git a/docs/API/config.md b/docs/API/config.md index 4ba455e..87619ac 100644 --- a/docs/API/config.md +++ b/docs/API/config.md @@ -26,3 +26,35 @@ GET /config websockets_port: 64667, version: 2.0 } + +### Collection: Plugins + +**URI:** /config/plugins + +**Methods:** + +* **GET**: Retrieve a summarized list of all UI Plugins. + +#### Examples +GET /plugins +[{'name': 'pluginA', 'enabled': True, "depends":['pluginB'], "is_dependency_of":[]}, + {'name': 'pluginB', 'enabled': False, "depends":[], "is_dependency_of":['pluginA']}] + +### Resource: Plugins + +**URI:** /config/plugins/*:name* + +Represents the current state of a given WoK plug-in. + +**Methods:** + +* **GET**: Retrieve the state of the plug-in. + * name: The name of the plug-in. + * enabled: True if the plug-in is currently enabled in WoK, False otherwise. +
You forgot to add the description to depends and is_dependency_of parameters
v4
+* **POST**: *See Plugin Actions* + +**Actions (POST):** + +* enable: Enable the plug-in in the configuration file. +* disable: Disable the plug-in in the configuration file.
As you are now doing the change on the fly, I'd say to add it to the description action as well.
v4
diff --git a/docs/API/plugins.md b/docs/API/plugins.md deleted file mode 100644 index aaa37b5..0000000 --- a/docs/API/plugins.md +++ /dev/null @@ -1,13 +0,0 @@ -## REST API Specification for Plugins - -### Collection: Plugins - -**URI:** /plugins - -**Methods:** - -* **GET**: Retrieve a summarized list names of all UI Plugins - -#### Examples -GET /plugins -[pluginA, pluginB, pluginC] diff --git a/src/wok/config.py.in b/src/wok/config.py.in index 9573e66..0e46b17 100644 --- a/src/wok/config.py.in +++ b/src/wok/config.py.in @@ -1,7 +1,7 @@ # # Project Wok # -# Copyright IBM Corp, 2015-2016 +# Copyright IBM Corp, 2015-2017 # # Code derived from Project Kimchi # @@ -269,6 +269,7 @@ def _get_config(): config.set("server", "environment", "production") config.set('server', 'max_body_size', '4*1024*1024') config.set("server", "server_root", "")
+ config.set("server", "test", "true")
The default value should be 'false' as by default Wok runs on production mode.
Yeah I've tested with both "true" and "false" there and it turned out that "true" allows for less code changes. Reason is that when running in production mode WoK the option does not exist and the value of this option is set to 'None', even when setting this default to "false".
config.add_section("authentication") config.set("authentication", "method", "pam") config.set("authentication", "ldap_server", "") @@ -278,6 +279,8 @@ def _get_config(): config.add_section("logging") config.set("logging", "log_dir", paths.log_dir) config.set("logging", "log_level", DEFAULT_LOG_LEVEL)
+ config.set("logging", "access_log", "") + config.set("logging", "error_log", "")
Seems a rebase issue here. There was a patch to remove those configuration (access_log and error_log)
No it isn't, I've added the options because the command line has them. Given than the plug-ins use them in the load process I wanted to send the exact same values in the "def get_plugin_config_options()" call. Why were those options removed? If no plug-in is using those values I think we can safely remove them here too.
config_file = os.path.join(paths.conf_dir, 'wok.conf') if os.path.exists(config_file): diff --git a/src/wok/control/config.py b/src/wok/control/config.py index 419abc0..05383c7 100644 --- a/src/wok/control/config.py +++ b/src/wok/control/config.py @@ -17,7 +17,7 @@ # License along with this library; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
-from wok.control.base import Resource +from wok.control.base import Collection, Resource from wok.control.utils import UrlSubNode
@@ -28,15 +28,44 @@ CONFIG_REQUESTS = { }
+PLUGIN_REQUESTS = { + 'POST': { + 'enable': "WOKPLUGIN0001L", + 'disable': "WOKPLUGIN0002L", + }, +} + + @UrlSubNode("config") class Config(Resource): def __init__(self, model, id=None): super(Config, self).__init__(model, id) self.uri_fmt = '/config/%s' self.admin_methods = ['POST'] + self.plugins = Plugins(self.model) self.log_map = CONFIG_REQUESTS self.reload = self.generate_action_handler('reload')
@property def data(self): return self.info + + +class Plugins(Collection): + def __init__(self, model): + super(Plugins, self).__init__(model) + self.resource = Plugin + + +class Plugin(Resource): + def __init__(self, model, ident=None): + super(Plugin, self).__init__(model, ident) + self.ident = ident + self.uri_fmt = "/config/plugins/%s" + self.log_map = PLUGIN_REQUESTS + self.enable = self.generate_action_handler('enable') + self.disable = self.generate_action_handler('disable') +
Please, set self.admin_methods = [POST] to restrict enable/disable operations to admin users. Also update test_authorization.py to validate that. Use the sample plugin in the tests.
v4
+ @property + def data(self): + return self.info diff --git a/src/wok/control/plugins.py b/src/wok/control/plugins.py deleted file mode 100644 index 57dfa1b..0000000 --- a/src/wok/control/plugins.py +++ /dev/null @@ -1,29 +0,0 @@ -# -# Project Wok -# -# Copyright IBM Corp, 2015-2016 -# -# Code derived from Project Kimchi -# -# This library is free software; you can redistribute it and/or -# modify it under the terms of the GNU Lesser General Public -# License as published by the Free Software Foundation; either -# version 2.1 of the License, or (at your option) any later version. -# -# This library 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 -# Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public -# License along with this library; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - -from wok.control.base import SimpleCollection -from wok.control.utils import UrlSubNode - - -@UrlSubNode("plugins") -class Plugins(SimpleCollection): - def __init__(self, model): - super(Plugins, self).__init__(model) diff --git a/src/wok/i18n.py b/src/wok/i18n.py index 935c9c1..d44c2f6 100644 --- a/src/wok/i18n.py +++ b/src/wok/i18n.py @@ -57,6 +57,8 @@ messages = {
"WOKCONFIG0001I": _("WoK is going to restart. Existing WoK connections will be closed."),
+ "WOKPLUGIN0001E": _("Unable to find plug-in %(name)s"), + # These messages (ending with L) are for user log purposes "WOKASYNC0001L": _("Successfully completed task '%(target_uri)s'"), "WOKASYNC0002L": _("Failed to complete task '%(target_uri)s'"), @@ -65,4 +67,6 @@ messages = { "WOKRES0001L": _("Request made on resource"), "WOKROOT0001L": _("User '%(username)s' login"), "WOKROOT0002L": _("User '%(username)s' logout"), + "WOKPLUGIN0001L": _("Enable plug-in %(ident)s."), + "WOKPLUGIN0002L": _("Disable plug-in %(ident)s."), } diff --git a/src/wok/model/plugins.py b/src/wok/model/plugins.py index 1b8ec5e..1b39e6c 100644 --- a/src/wok/model/plugins.py +++ b/src/wok/model/plugins.py @@ -1,7 +1,7 @@ # # Project Wok # -# Copyright IBM Corp, 2015-2016 +# Copyright IBM Corp, 2015-2017 # # Code derived from Project Kimchi # @@ -19,10 +19,11 @@ # License along with this library; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
-import cherrypy
-from wok.config import get_base_plugin_uri -from wok.utils import get_enabled_plugins +from wok.exception import NotFoundError +from wok.utils import get_all_affected_plugins_by_plugin +from wok.utils import get_plugin_dependencies, get_plugins, load_plugin_conf +from wok.utils import set_plugin_state
class PluginsModel(object): @@ -30,7 +31,30 @@ class PluginsModel(object): pass
def get_list(self): - # Will only return plugins that were loaded correctly by WOK and are - # properly configured in cherrypy - return [plugin for (plugin, config) in get_enabled_plugins() - if get_base_plugin_uri(plugin) in cherrypy.tree.apps.keys()] + return [plugin for (plugin, config) in get_plugins()] + + +class PluginModel(object): + def __init__(self, **kargs): + pass + + def lookup(self, name): + name = name.encode('utf-8') + + plugin_conf = load_plugin_conf(name) + if not plugin_conf: + raise NotFoundError("WOKPLUGIN0001E", {'name': name}) + + depends = get_plugin_dependencies(name) + is_dependency_of = get_all_affected_plugins_by_plugin(name) + + return {"name": name, "enabled": plugin_conf['wok']['enable'], + "depends": depends, "is_dependency_of": is_dependency_of} + + def enable(self, name): + name = name.encode('utf-8') + set_plugin_state(name, True) + + def disable(self, name): + name = name.encode('utf-8') + set_plugin_state(name, False) diff --git a/src/wok/server.py b/src/wok/server.py index 48f455b..9b49c1a 100644 --- a/src/wok/server.py +++ b/src/wok/server.py @@ -1,7 +1,7 @@ # # Project Wok # -# Copyright IBM Corp, 2015-2016 +# Copyright IBM Corp, 2015-2017 # # Code derived from Project Kimchi # @@ -28,14 +28,14 @@ import os from wok import auth from wok import config from wok.config import config as configParser -from wok.config import PluginConfig, WokConfig +from wok.config import WokConfig from wok.control import sub_nodes from wok.model import model from wok.proxy import check_proxy_config from wok.reqlogger import RequestLogger from wok.root import WokRoot from wok.safewatchedfilehandler import SafeWatchedFileHandler -from wok.utils import get_enabled_plugins, import_class +from wok.utils import get_enabled_plugins, load_plugin
LOGGING_LEVEL = {"debug": logging.DEBUG, @@ -153,56 +153,12 @@ class Server(object): self.app = cherrypy.tree.mount(WokRoot(model_instance, dev_env), options.server_root, self.configObj)
- self._load_plugins(options) + self._load_plugins() cherrypy.lib.sessions.init()
- def _load_plugins(self, options): + def _load_plugins(self): for plugin_name, plugin_config in get_enabled_plugins(): - try: - plugin_class = ('plugins.%s.%s' % - (plugin_name, - plugin_name[0].upper() + plugin_name[1:])) - del plugin_config['wok'] - plugin_config.update(PluginConfig(plugin_name)) - except KeyError: - continue - - try: - plugin_app = import_class(plugin_class)(options) - except (ImportError, Exception), e: - cherrypy.log.error_log.error( - "Failed to import plugin %s, " - "error: %s" % (plugin_class, e.message) - ) - continue - - # dynamically extend plugin config with custom data, if provided - get_custom_conf = getattr(plugin_app, "get_custom_conf", None) - if get_custom_conf is not None: - plugin_config.update(get_custom_conf()) - - # dynamically add tools.wokauth.on = True to extra plugin APIs - try: - sub_nodes = import_class('plugins.%s.control.sub_nodes' % - plugin_name) - - urlSubNodes = {} - for ident, node in sub_nodes.items(): - if node.url_auth: - ident = "/%s" % ident - urlSubNodes[ident] = {'tools.wokauth.on': True} - - plugin_config.update(urlSubNodes) - - except ImportError, e: - cherrypy.log.error_log.error( - "Failed to import subnodes for plugin %s, " - "error: %s" % (plugin_class, e.message) - ) - - cherrypy.tree.mount(plugin_app, - config.get_base_plugin_uri(plugin_name), - plugin_config) + load_plugin(plugin_name, plugin_config)
def start(self): # Subscribe to SignalHandler plugin diff --git a/src/wok/utils.py b/src/wok/utils.py index 9a08001..9e6bb8a 100644 --- a/src/wok/utils.py +++ b/src/wok/utils.py @@ -1,7 +1,7 @@ # # Project Wok # -# Copyright IBM Corp, 2015-2016 +# Copyright IBM Corp, 2015-2017 # # Code derived from Project Kimchi # @@ -37,9 +37,11 @@ import xml.etree.ElementTree as ET from cherrypy.lib.reprconf import Parser from datetime import datetime, timedelta from multiprocessing import Process, Queue +from optparse import Values from threading import Timer
-from wok.config import paths, PluginPaths +from wok import config +from wok.config import paths, PluginConfig, PluginPaths from wok.exception import InvalidParameter, TimeoutExpired from wok.stringutils import decode_value
@@ -57,13 +59,21 @@ def is_digit(value): return False
-def _load_plugin_conf(name): +def get_plugin_config_file(name): plugin_conf = PluginPaths(name).conf_file if not os.path.exists(plugin_conf): cherrypy.log.error_log.error("Plugin configuration file %s" " doesn't exist." % plugin_conf) - return + return None + return plugin_conf + + +def load_plugin_conf(name): try: + plugin_conf = get_plugin_config_file(name) + if not plugin_conf: + return None + return Parser().dict_from_file(plugin_conf) except ValueError as e: cherrypy.log.error_log.error("Failed to load plugin " @@ -71,22 +81,223 @@ def _load_plugin_conf(name): (plugin_conf, e.message))
-def get_enabled_plugins(): +def get_plugins(enabled_only=False): plugin_dir = paths.plugins_dir + try: dir_contents = os.listdir(plugin_dir) except OSError: return + + test_mode = config.config.get('server', 'test').lower() == 'true' + for name in dir_contents: if os.path.isdir(os.path.join(plugin_dir, name)): - plugin_config = _load_plugin_conf(name) + if name == 'sample' and not test_mode: + continue + + plugin_config = load_plugin_conf(name) + if not plugin_config: + continue try: - if plugin_config['wok']['enable']: - yield (name, plugin_config) + if plugin_config['wok']['enable'] is None: + continue + + plugin_enabled = plugin_config['wok']['enable'] + if enabled_only and not plugin_enabled: + continue + + yield (name, plugin_config) except (TypeError, KeyError): continue
+def get_enabled_plugins(): + return get_plugins(enabled_only=True) + + +def get_plugin_app_mounted_in_cherrypy(name): + plugin_uri = '/plugins/' + name + return cherrypy.tree.apps.get(plugin_uri, None) + + +def get_plugin_dependencies(name): + app = get_plugin_app_mounted_in_cherrypy(name) + if app is None or not hasattr(app.root, 'depends'): + return [] + return app.root.depends + + +def get_all_plugins_dependent_on(name): + if not cherrypy.tree.apps: + return [] + + dependencies = [] + for plugin, app in cherrypy.tree.apps.iteritems(): + if hasattr(app.root, 'depends') and name in app.root.depends: + dependencies.append(plugin.replace('/plugins/', '')) + + return dependencies + + +def get_all_affected_plugins_by_plugin(name): + dependencies = get_all_plugins_dependent_on(name) + if len(dependencies) == 0: + return [] + + all_affected_plugins = dependencies + for dep in dependencies: + all_affected_plugins += get_all_affected_plugins_by_plugin(dep) + + return all_affected_plugins + + +def disable_plugin(name): + plugin_deps = get_all_affected_plugins_by_plugin(name) + + for dep in set(plugin_deps): + update_plugin_config_file(dep, False) + update_cherrypy_mounted_tree(dep, False) + + update_plugin_config_file(name, False) + update_cherrypy_mounted_tree(name, False) + + +def enable_plugin(name): + update_plugin_config_file(name, True) + update_cherrypy_mounted_tree(name, True) + + plugin_deps = get_plugin_dependencies(name) + + for dep in set(plugin_deps): + enable_plugin(dep) + + +def set_plugin_state(name, state): + if state is False: + disable_plugin(name) + else: + enable_plugin(name) + + +def update_plugin_config_file(name, state): + plugin_conf = get_plugin_config_file(name) + if not plugin_conf: + return + + config_contents = None + + with open(plugin_conf, 'r') as f: + config_contents = f.readlines() + + wok_section_found = False + + pattern = re.compile("^\s*enable\s*=\s*") + + for i in range(0, len(config_contents)): + if config_contents[i] == '[wok]\n': + wok_section_found = True + continue + + if pattern.match(config_contents[i]) and wok_section_found: + config_contents[i] = 'enable = %s\n' % str(state) + break + + with open(plugin_conf, 'w') as f: + f.writelines(config_contents) + + +def load_plugin(plugin_name, plugin_config): + try: + plugin_class = ('plugins.%s.%s' % + (plugin_name, + plugin_name[0].upper() + plugin_name[1:])) + del plugin_config['wok'] + plugin_config.update(PluginConfig(plugin_name)) + except KeyError: + return + + try: + options = get_plugin_config_options() + plugin_app = import_class(plugin_class)(options) + except (ImportError, Exception), e: + cherrypy.log.error_log.error( + "Failed to import plugin %s, " + "error: %s" % (plugin_class, e.message) + ) + return + + # dynamically extend plugin config with custom data, if provided + get_custom_conf = getattr(plugin_app, "get_custom_conf", None) + if get_custom_conf is not None: + plugin_config.update(get_custom_conf()) + + # dynamically add tools.wokauth.on = True to extra plugin APIs + try: + sub_nodes = import_class('plugins.%s.control.sub_nodes' % + plugin_name) + + urlSubNodes = {} + for ident, node in sub_nodes.items(): + if node.url_auth: + ident = "/%s" % ident + urlSubNodes[ident] = {'tools.wokauth.on': True} + + plugin_config.update(urlSubNodes) + + except ImportError, e: + cherrypy.log.error_log.error( + "Failed to import subnodes for plugin %s, " + "error: %s" % (plugin_class, e.message) + ) + + cherrypy.tree.mount(plugin_app, + config.get_base_plugin_uri(plugin_name), + plugin_config) + + +def is_plugin_mounted_in_cherrypy(plugin_uri): + return cherrypy.tree.apps.get(plugin_uri) is not None + + +def update_cherrypy_mounted_tree(plugin, state): + plugin_uri = '/plugin/' + plugin + + if state is False and is_plugin_mounted_in_cherrypy(plugin_uri): + del cherrypy.tree.apps[plugin_uri] + + if state is True and not is_plugin_mounted_in_cherrypy(plugin_uri): + plugin_config = load_plugin_conf(plugin) + load_plugin(plugin, plugin_config) + + +def get_plugin_config_options(): + options = Values() + + options.websockets_port = config.config.getint('server', + 'websockets_port') + options.cherrypy_port = config.config.getint('server', + 'cherrypy_port') + options.proxy_port = config.config.getint('server', 'proxy_port') + options.session_timeout = config.config.getint('server', + 'session_timeout') + + options.test = config.config.get('server', 'test') + if options.test == 'None': + options.test = None + + options.environment = config.config.get('server', 'environment') + options.server_root = config.config.get('server', 'server_root') + options.max_body_size = config.config.get('server', 'max_body_size') + + options.log_dir = config.config.get('logging', 'log_dir') + options.log_level = config.config.get('logging', 'log_level') + options.access_log = config.config.get('logging', 'access_log') + options.error_log = config.config.get('logging', 'error_log') + + return options + + def get_all_tabs(): files = [os.path.join(paths.ui_dir, 'config/tab-ext.xml')]
diff --git a/tests/test_api.py b/tests/test_api.py index 1430bc1..6fbee75 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -26,6 +26,8 @@ import utils from functools import partial
from wok.asynctask import AsyncTask +from wok.utils import set_plugin_state +from wok.rollbackcontext import RollbackContext
test_server = None model = None @@ -54,6 +56,63 @@ class APITests(unittest.TestCase): "server_root"] self.assertEquals(sorted(keys), sorted(conf.keys()))
+ def test_config_plugins(self): + resp = self.request('/config/plugins') + self.assertEquals(200, resp.status) + + plugins = json.loads(resp.read()) + if len(plugins) == 0: + return + + plugin_name = '' + plugin_state = '' + for p in plugins: + if p.get('name') == 'sample': + plugin_name = p.get('name').encode('utf-8') + plugin_state = p.get('enabled') + break + else: + return + + with RollbackContext() as rollback: + rollback.prependDefer(set_plugin_state, plugin_name, + plugin_state) + + resp = self.request('/config/plugins/sample') + self.assertEquals(200, resp.status) + + resp = self.request('/config/plugins/sample/enable', + '{}', 'POST') + self.assertEquals(200, resp.status) + + resp = self.request('/config/plugins') + self.assertEquals(200, resp.status) + plugins = json.loads(resp.read()) + + for p in plugins: + if p.get('name') == 'sample': + plugin_state = p.get('enabled') + break + self.assertTrue(plugin_state) + + resp = self.request('/config/plugins/sample/disable', + '{}', 'POST') + self.assertEquals(200, resp.status) + + resp = self.request('/config/plugins') + self.assertEquals(200, resp.status) + plugins = json.loads(resp.read()) + + for p in plugins: + if p.get('name') == 'sample': + plugin_state = p.get('enabled') + break + self.assertFalse(plugin_state) + + def test_plugins_api_404(self): + resp = self.request('/plugins') + self.assertEquals(404, resp.status) + def test_user_log(self): # Login and logout to make sure there there are entries in user log hdrs = {'AUTHORIZATION': '', diff --git a/tests/test_utils.py b/tests/test_utils.py index e7fd264..e63e1a2 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -19,10 +19,14 @@ # License along with this library; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+import mock +import os +import tempfile import unittest
from wok.exception import InvalidParameter -from wok.utils import convert_data_size +from wok.rollbackcontext import RollbackContext +from wok.utils import convert_data_size, set_plugin_state
class UtilsTests(unittest.TestCase): @@ -69,3 +73,72 @@ class UtilsTests(unittest.TestCase):
for d in success_data: self.assertEquals(d['got'], d['want']) + + def _get_fake_config_file_content(self, enable=True): + return """\ +[a_random_section] +# a random section for testing purposes +enable = 1 + +[wok] +# Enable plugin on Wok server (values: True|False) +enable = %s + +[fakeplugin] +# Yet another comment on this config file +enable = 2 +very_interesting_option = True +""" % str(enable) + + def _get_config_file_template(self, enable=True): + return """\ +[a_random_section] +# a random section for testing purposes +enable = 1 + +[wok] +# Enable plugin on Wok server (values: True|False) +enable = %s + +[fakeplugin] +# Yet another comment on this config file +enable = 2 +very_interesting_option = True +""" % str(enable) + + def _create_fake_config_file(self): + _, tmp_file_name = tempfile.mkstemp(suffix='.conf') + + config_contents = self._get_fake_config_file_content() + with open(tmp_file_name, 'w') as f: + f.writelines(config_contents) + + return tmp_file_name + + @mock.patch('wok.utils.get_plugin_config_file') + @mock.patch('wok.utils.update_cherrypy_mounted_tree') + def test_set_plugin_state(self, mock_update_cherrypy, mock_config_file): + mock_update_cherrypy.return_value = True + + with RollbackContext() as rollback: + + config_file_name = self._create_fake_config_file() + rollback.prependDefer(os.remove, config_file_name) + + mock_config_file.return_value = config_file_name + + set_plugin_state('pluginA', False) + with open(config_file_name, 'r') as f: + updated_conf = f.read() + self.assertEqual( + updated_conf, + self._get_config_file_template(enable=False) + ) + + set_plugin_state('pluginA', True) + with open(config_file_name, 'r') as f: + updated_conf = f.read() + self.assertEqual( + updated_conf, + self._get_config_file_template(enable=True) + )
_______________________________________________ Kimchi-devel mailing list Kimchi-devel@ovirt.org http://lists.ovirt.org/mailman/listinfo/kimchi-devel

On 02/03/2017 12:32 PM, Daniel Henrique Barboza wrote:
On 02/03/2017 12:21 PM, Aline Manera wrote:
Hi Daniel,
On 02/01/2017 10:03 AM, dhbarboza82@gmail.com wrote:
From: Daniel Henrique Barboza <danielhb@linux.vnet.ibm.com>
This patch adds a backend for a new API called /config/plugins.
The idea is to be able to retrieve the 'enable' status of WoK plug-ins and also provide a way to enable/disable them. The enable|disable operation consists on two steps:
- changing the 'enable=' attribute of the [WoK] section of the plugin .conf file;
- the plug-in is removed/added in the cherrypy.tree on the fly.
Several changes/enhancements in the backend were made to make this possible, such as:
- added the 'test' parameter in the config.py.in file to make it available for reading in the backend. This parameter indicates whether WoK is running in test mode;
- 'load_plugin' was moved from server.py to utils.py to make it available for utils functions to load plug-ins;
- a new 'depends' attribute is now being considered in the root class of each plug-in. This is an array that indicates all the plug-ins it has a dependency on. For example, Kimchi would mark self.depends = ['gingerbase'] in its root file. The absence of this attribute means that the plug-in does not have any dependency aside from WoK.
Previous /plugins API were removed because it was redundant with this work.
Uni tests included.
Signed-off-by: Daniel Henrique Barboza <danielhb@linux.vnet.ibm.com> --- docs/API/config.md | 32 +++++++ docs/API/plugins.md | 13 --- src/wok/config.py.in | 5 +- src/wok/control/config.py | 31 ++++++- src/wok/control/plugins.py | 29 ------ src/wok/i18n.py | 4 + src/wok/model/plugins.py | 40 ++++++-- src/wok/server.py | 56 ++--------- src/wok/utils.py | 227 +++++++++++++++++++++++++++++++++++++++++++-- tests/test_api.py | 59 ++++++++++++ tests/test_utils.py | 75 ++++++++++++++- 11 files changed, 460 insertions(+), 111 deletions(-) delete mode 100644 docs/API/plugins.md delete mode 100644 src/wok/control/plugins.py
diff --git a/docs/API/config.md b/docs/API/config.md index 4ba455e..87619ac 100644 --- a/docs/API/config.md +++ b/docs/API/config.md @@ -26,3 +26,35 @@ GET /config websockets_port: 64667, version: 2.0 } + +### Collection: Plugins + +**URI:** /config/plugins + +**Methods:** + +* **GET**: Retrieve a summarized list of all UI Plugins. + +#### Examples +GET /plugins +[{'name': 'pluginA', 'enabled': True, "depends":['pluginB'], "is_dependency_of":[]}, + {'name': 'pluginB', 'enabled': False, "depends":[], "is_dependency_of":['pluginA']}] + +### Resource: Plugins + +**URI:** /config/plugins/*:name* + +Represents the current state of a given WoK plug-in. + +**Methods:** + +* **GET**: Retrieve the state of the plug-in. + * name: The name of the plug-in. + * enabled: True if the plug-in is currently enabled in WoK, False otherwise. +
You forgot to add the description to depends and is_dependency_of parameters
v4
+* **POST**: *See Plugin Actions* + +**Actions (POST):** + +* enable: Enable the plug-in in the configuration file. +* disable: Disable the plug-in in the configuration file.
As you are now doing the change on the fly, I'd say to add it to the description action as well.
v4
diff --git a/docs/API/plugins.md b/docs/API/plugins.md deleted file mode 100644 index aaa37b5..0000000 --- a/docs/API/plugins.md +++ /dev/null @@ -1,13 +0,0 @@ -## REST API Specification for Plugins - -### Collection: Plugins - -**URI:** /plugins - -**Methods:** - -* **GET**: Retrieve a summarized list names of all UI Plugins - -#### Examples -GET /plugins -[pluginA, pluginB, pluginC] diff --git a/src/wok/config.py.in b/src/wok/config.py.in index 9573e66..0e46b17 100644 --- a/src/wok/config.py.in +++ b/src/wok/config.py.in @@ -1,7 +1,7 @@ # # Project Wok # -# Copyright IBM Corp, 2015-2016 +# Copyright IBM Corp, 2015-2017 # # Code derived from Project Kimchi # @@ -269,6 +269,7 @@ def _get_config(): config.set("server", "environment", "production") config.set('server', 'max_body_size', '4*1024*1024') config.set("server", "server_root", "")
+ config.set("server", "test", "true")
The default value should be 'false' as by default Wok runs on production mode.
Yeah I've tested with both "true" and "false" there and it turned out that "true" allows for less code changes. Reason is that when running in production mode WoK the option does not exist and the value of this option is set to 'None', even when setting this default to "false".
Maybe set it to None so. 'true' is not a right value IMO
config.add_section("authentication") config.set("authentication", "method", "pam") config.set("authentication", "ldap_server", "") @@ -278,6 +279,8 @@ def _get_config(): config.add_section("logging") config.set("logging", "log_dir", paths.log_dir) config.set("logging", "log_level", DEFAULT_LOG_LEVEL)
+ config.set("logging", "access_log", "") + config.set("logging", "error_log", "")
Seems a rebase issue here. There was a patch to remove those configuration (access_log and error_log)
No it isn't, I've added the options because the command line has them.
That is not true. I did a patch that was applied 'recently' to remove them from command line as they are not present in the config file. Check da528f461fdf0c82dbf864d5c1309cd9f159a1f0 for details.
Given than the plug-ins use them in the load process I wanted to send the exact same values in the "def get_plugin_config_options()" call.
Why were those options removed? If no plug-in is using those values I think we can safely remove them here too.
The log parameters is only used by Wok to set them on cherrypy. The plugins only use wok_log to get the log instance to use.
config_file = os.path.join(paths.conf_dir, 'wok.conf') if os.path.exists(config_file): diff --git a/src/wok/control/config.py b/src/wok/control/config.py index 419abc0..05383c7 100644 --- a/src/wok/control/config.py +++ b/src/wok/control/config.py @@ -17,7 +17,7 @@ # License along with this library; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
-from wok.control.base import Resource +from wok.control.base import Collection, Resource from wok.control.utils import UrlSubNode
@@ -28,15 +28,44 @@ CONFIG_REQUESTS = { }
+PLUGIN_REQUESTS = { + 'POST': { + 'enable': "WOKPLUGIN0001L", + 'disable': "WOKPLUGIN0002L", + }, +} + + @UrlSubNode("config") class Config(Resource): def __init__(self, model, id=None): super(Config, self).__init__(model, id) self.uri_fmt = '/config/%s' self.admin_methods = ['POST'] + self.plugins = Plugins(self.model) self.log_map = CONFIG_REQUESTS self.reload = self.generate_action_handler('reload')
@property def data(self): return self.info + + +class Plugins(Collection): + def __init__(self, model): + super(Plugins, self).__init__(model) + self.resource = Plugin + + +class Plugin(Resource): + def __init__(self, model, ident=None): + super(Plugin, self).__init__(model, ident) + self.ident = ident + self.uri_fmt = "/config/plugins/%s" + self.log_map = PLUGIN_REQUESTS + self.enable = self.generate_action_handler('enable') + self.disable = self.generate_action_handler('disable') +
Please, set self.admin_methods = [POST] to restrict enable/disable operations to admin users. Also update test_authorization.py to validate that. Use the sample plugin in the tests.
v4
+ @property + def data(self): + return self.info diff --git a/src/wok/control/plugins.py b/src/wok/control/plugins.py deleted file mode 100644 index 57dfa1b..0000000 --- a/src/wok/control/plugins.py +++ /dev/null @@ -1,29 +0,0 @@ -# -# Project Wok -# -# Copyright IBM Corp, 2015-2016 -# -# Code derived from Project Kimchi -# -# This library is free software; you can redistribute it and/or -# modify it under the terms of the GNU Lesser General Public -# License as published by the Free Software Foundation; either -# version 2.1 of the License, or (at your option) any later version. -# -# This library 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 -# Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public -# License along with this library; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - -from wok.control.base import SimpleCollection -from wok.control.utils import UrlSubNode - - -@UrlSubNode("plugins") -class Plugins(SimpleCollection): - def __init__(self, model): - super(Plugins, self).__init__(model) diff --git a/src/wok/i18n.py b/src/wok/i18n.py index 935c9c1..d44c2f6 100644 --- a/src/wok/i18n.py +++ b/src/wok/i18n.py @@ -57,6 +57,8 @@ messages = {
"WOKCONFIG0001I": _("WoK is going to restart. Existing WoK connections will be closed."),
+ "WOKPLUGIN0001E": _("Unable to find plug-in %(name)s"), + # These messages (ending with L) are for user log purposes "WOKASYNC0001L": _("Successfully completed task '%(target_uri)s'"), "WOKASYNC0002L": _("Failed to complete task '%(target_uri)s'"), @@ -65,4 +67,6 @@ messages = { "WOKRES0001L": _("Request made on resource"), "WOKROOT0001L": _("User '%(username)s' login"), "WOKROOT0002L": _("User '%(username)s' logout"), + "WOKPLUGIN0001L": _("Enable plug-in %(ident)s."), + "WOKPLUGIN0002L": _("Disable plug-in %(ident)s."), } diff --git a/src/wok/model/plugins.py b/src/wok/model/plugins.py index 1b8ec5e..1b39e6c 100644 --- a/src/wok/model/plugins.py +++ b/src/wok/model/plugins.py @@ -1,7 +1,7 @@ # # Project Wok # -# Copyright IBM Corp, 2015-2016 +# Copyright IBM Corp, 2015-2017 # # Code derived from Project Kimchi # @@ -19,10 +19,11 @@ # License along with this library; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
-import cherrypy
-from wok.config import get_base_plugin_uri -from wok.utils import get_enabled_plugins +from wok.exception import NotFoundError +from wok.utils import get_all_affected_plugins_by_plugin +from wok.utils import get_plugin_dependencies, get_plugins, load_plugin_conf +from wok.utils import set_plugin_state
class PluginsModel(object): @@ -30,7 +31,30 @@ class PluginsModel(object): pass
def get_list(self): - # Will only return plugins that were loaded correctly by WOK and are - # properly configured in cherrypy - return [plugin for (plugin, config) in get_enabled_plugins() - if get_base_plugin_uri(plugin) in cherrypy.tree.apps.keys()] + return [plugin for (plugin, config) in get_plugins()] + + +class PluginModel(object): + def __init__(self, **kargs): + pass + + def lookup(self, name): + name = name.encode('utf-8') + + plugin_conf = load_plugin_conf(name) + if not plugin_conf: + raise NotFoundError("WOKPLUGIN0001E", {'name': name}) + + depends = get_plugin_dependencies(name) + is_dependency_of = get_all_affected_plugins_by_plugin(name) + + return {"name": name, "enabled": plugin_conf['wok']['enable'], + "depends": depends, "is_dependency_of": is_dependency_of} + + def enable(self, name): + name = name.encode('utf-8') + set_plugin_state(name, True) + + def disable(self, name): + name = name.encode('utf-8') + set_plugin_state(name, False) diff --git a/src/wok/server.py b/src/wok/server.py index 48f455b..9b49c1a 100644 --- a/src/wok/server.py +++ b/src/wok/server.py @@ -1,7 +1,7 @@ # # Project Wok # -# Copyright IBM Corp, 2015-2016 +# Copyright IBM Corp, 2015-2017 # # Code derived from Project Kimchi # @@ -28,14 +28,14 @@ import os from wok import auth from wok import config from wok.config import config as configParser -from wok.config import PluginConfig, WokConfig +from wok.config import WokConfig from wok.control import sub_nodes from wok.model import model from wok.proxy import check_proxy_config from wok.reqlogger import RequestLogger from wok.root import WokRoot from wok.safewatchedfilehandler import SafeWatchedFileHandler -from wok.utils import get_enabled_plugins, import_class +from wok.utils import get_enabled_plugins, load_plugin
LOGGING_LEVEL = {"debug": logging.DEBUG, @@ -153,56 +153,12 @@ class Server(object): self.app = cherrypy.tree.mount(WokRoot(model_instance, dev_env), options.server_root, self.configObj)
- self._load_plugins(options) + self._load_plugins() cherrypy.lib.sessions.init()
- def _load_plugins(self, options): + def _load_plugins(self): for plugin_name, plugin_config in get_enabled_plugins(): - try: - plugin_class = ('plugins.%s.%s' % - (plugin_name, - plugin_name[0].upper() + plugin_name[1:])) - del plugin_config['wok'] - plugin_config.update(PluginConfig(plugin_name)) - except KeyError: - continue - - try: - plugin_app = import_class(plugin_class)(options) - except (ImportError, Exception), e: - cherrypy.log.error_log.error( - "Failed to import plugin %s, " - "error: %s" % (plugin_class, e.message) - ) - continue - - # dynamically extend plugin config with custom data, if provided - get_custom_conf = getattr(plugin_app, "get_custom_conf", None) - if get_custom_conf is not None: - plugin_config.update(get_custom_conf()) - - # dynamically add tools.wokauth.on = True to extra plugin APIs - try: - sub_nodes = import_class('plugins.%s.control.sub_nodes' % - plugin_name) - - urlSubNodes = {} - for ident, node in sub_nodes.items(): - if node.url_auth: - ident = "/%s" % ident - urlSubNodes[ident] = {'tools.wokauth.on': True} - - plugin_config.update(urlSubNodes) - - except ImportError, e: - cherrypy.log.error_log.error( - "Failed to import subnodes for plugin %s, " - "error: %s" % (plugin_class, e.message) - ) - - cherrypy.tree.mount(plugin_app, - config.get_base_plugin_uri(plugin_name), - plugin_config) + load_plugin(plugin_name, plugin_config)
def start(self): # Subscribe to SignalHandler plugin diff --git a/src/wok/utils.py b/src/wok/utils.py index 9a08001..9e6bb8a 100644 --- a/src/wok/utils.py +++ b/src/wok/utils.py @@ -1,7 +1,7 @@ # # Project Wok # -# Copyright IBM Corp, 2015-2016 +# Copyright IBM Corp, 2015-2017 # # Code derived from Project Kimchi # @@ -37,9 +37,11 @@ import xml.etree.ElementTree as ET from cherrypy.lib.reprconf import Parser from datetime import datetime, timedelta from multiprocessing import Process, Queue +from optparse import Values from threading import Timer
-from wok.config import paths, PluginPaths +from wok import config +from wok.config import paths, PluginConfig, PluginPaths from wok.exception import InvalidParameter, TimeoutExpired from wok.stringutils import decode_value
@@ -57,13 +59,21 @@ def is_digit(value): return False
-def _load_plugin_conf(name): +def get_plugin_config_file(name): plugin_conf = PluginPaths(name).conf_file if not os.path.exists(plugin_conf): cherrypy.log.error_log.error("Plugin configuration file %s" " doesn't exist." % plugin_conf) - return + return None + return plugin_conf + + +def load_plugin_conf(name): try: + plugin_conf = get_plugin_config_file(name) + if not plugin_conf: + return None + return Parser().dict_from_file(plugin_conf) except ValueError as e: cherrypy.log.error_log.error("Failed to load plugin " @@ -71,22 +81,223 @@ def _load_plugin_conf(name): (plugin_conf, e.message))
-def get_enabled_plugins(): +def get_plugins(enabled_only=False): plugin_dir = paths.plugins_dir + try: dir_contents = os.listdir(plugin_dir) except OSError: return + + test_mode = config.config.get('server', 'test').lower() == 'true' + for name in dir_contents: if os.path.isdir(os.path.join(plugin_dir, name)): - plugin_config = _load_plugin_conf(name) + if name == 'sample' and not test_mode: + continue + + plugin_config = load_plugin_conf(name) + if not plugin_config: + continue try: - if plugin_config['wok']['enable']: - yield (name, plugin_config) + if plugin_config['wok']['enable'] is None: + continue + + plugin_enabled = plugin_config['wok']['enable'] + if enabled_only and not plugin_enabled: + continue + + yield (name, plugin_config) except (TypeError, KeyError): continue
+def get_enabled_plugins(): + return get_plugins(enabled_only=True) + + +def get_plugin_app_mounted_in_cherrypy(name): + plugin_uri = '/plugins/' + name + return cherrypy.tree.apps.get(plugin_uri, None) + + +def get_plugin_dependencies(name): + app = get_plugin_app_mounted_in_cherrypy(name) + if app is None or not hasattr(app.root, 'depends'): + return [] + return app.root.depends + + +def get_all_plugins_dependent_on(name): + if not cherrypy.tree.apps: + return [] + + dependencies = [] + for plugin, app in cherrypy.tree.apps.iteritems(): + if hasattr(app.root, 'depends') and name in app.root.depends: + dependencies.append(plugin.replace('/plugins/', '')) + + return dependencies + + +def get_all_affected_plugins_by_plugin(name): + dependencies = get_all_plugins_dependent_on(name) + if len(dependencies) == 0: + return [] + + all_affected_plugins = dependencies + for dep in dependencies: + all_affected_plugins += get_all_affected_plugins_by_plugin(dep) + + return all_affected_plugins + + +def disable_plugin(name): + plugin_deps = get_all_affected_plugins_by_plugin(name) + + for dep in set(plugin_deps): + update_plugin_config_file(dep, False) + update_cherrypy_mounted_tree(dep, False) + + update_plugin_config_file(name, False) + update_cherrypy_mounted_tree(name, False) + + +def enable_plugin(name): + update_plugin_config_file(name, True) + update_cherrypy_mounted_tree(name, True) + + plugin_deps = get_plugin_dependencies(name) + + for dep in set(plugin_deps): + enable_plugin(dep) + + +def set_plugin_state(name, state): + if state is False: + disable_plugin(name) + else: + enable_plugin(name) + + +def update_plugin_config_file(name, state): + plugin_conf = get_plugin_config_file(name) + if not plugin_conf: + return + + config_contents = None + + with open(plugin_conf, 'r') as f: + config_contents = f.readlines() + + wok_section_found = False + + pattern = re.compile("^\s*enable\s*=\s*") + + for i in range(0, len(config_contents)): + if config_contents[i] == '[wok]\n': + wok_section_found = True + continue + + if pattern.match(config_contents[i]) and wok_section_found: + config_contents[i] = 'enable = %s\n' % str(state) + break + + with open(plugin_conf, 'w') as f: + f.writelines(config_contents) + + +def load_plugin(plugin_name, plugin_config): + try: + plugin_class = ('plugins.%s.%s' % + (plugin_name, + plugin_name[0].upper() + plugin_name[1:])) + del plugin_config['wok'] + plugin_config.update(PluginConfig(plugin_name)) + except KeyError: + return + + try: + options = get_plugin_config_options() + plugin_app = import_class(plugin_class)(options) + except (ImportError, Exception), e: + cherrypy.log.error_log.error( + "Failed to import plugin %s, " + "error: %s" % (plugin_class, e.message) + ) + return + + # dynamically extend plugin config with custom data, if provided + get_custom_conf = getattr(plugin_app, "get_custom_conf", None) + if get_custom_conf is not None: + plugin_config.update(get_custom_conf()) + + # dynamically add tools.wokauth.on = True to extra plugin APIs + try: + sub_nodes = import_class('plugins.%s.control.sub_nodes' % + plugin_name) + + urlSubNodes = {} + for ident, node in sub_nodes.items(): + if node.url_auth: + ident = "/%s" % ident + urlSubNodes[ident] = {'tools.wokauth.on': True} + + plugin_config.update(urlSubNodes) + + except ImportError, e: + cherrypy.log.error_log.error( + "Failed to import subnodes for plugin %s, " + "error: %s" % (plugin_class, e.message) + ) + + cherrypy.tree.mount(plugin_app, + config.get_base_plugin_uri(plugin_name), + plugin_config) + + +def is_plugin_mounted_in_cherrypy(plugin_uri): + return cherrypy.tree.apps.get(plugin_uri) is not None + + +def update_cherrypy_mounted_tree(plugin, state): + plugin_uri = '/plugin/' + plugin + + if state is False and is_plugin_mounted_in_cherrypy(plugin_uri): + del cherrypy.tree.apps[plugin_uri] + + if state is True and not is_plugin_mounted_in_cherrypy(plugin_uri): + plugin_config = load_plugin_conf(plugin) + load_plugin(plugin, plugin_config) + + +def get_plugin_config_options(): + options = Values() + + options.websockets_port = config.config.getint('server', + 'websockets_port') + options.cherrypy_port = config.config.getint('server', + 'cherrypy_port') + options.proxy_port = config.config.getint('server', 'proxy_port') + options.session_timeout = config.config.getint('server', + 'session_timeout') + + options.test = config.config.get('server', 'test') + if options.test == 'None': + options.test = None + + options.environment = config.config.get('server', 'environment') + options.server_root = config.config.get('server', 'server_root') + options.max_body_size = config.config.get('server', 'max_body_size') + + options.log_dir = config.config.get('logging', 'log_dir') + options.log_level = config.config.get('logging', 'log_level') + options.access_log = config.config.get('logging', 'access_log') + options.error_log = config.config.get('logging', 'error_log') + + return options + + def get_all_tabs(): files = [os.path.join(paths.ui_dir, 'config/tab-ext.xml')]
diff --git a/tests/test_api.py b/tests/test_api.py index 1430bc1..6fbee75 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -26,6 +26,8 @@ import utils from functools import partial
from wok.asynctask import AsyncTask +from wok.utils import set_plugin_state +from wok.rollbackcontext import RollbackContext
test_server = None model = None @@ -54,6 +56,63 @@ class APITests(unittest.TestCase): "server_root"] self.assertEquals(sorted(keys), sorted(conf.keys()))
+ def test_config_plugins(self): + resp = self.request('/config/plugins') + self.assertEquals(200, resp.status) + + plugins = json.loads(resp.read()) + if len(plugins) == 0: + return + + plugin_name = '' + plugin_state = '' + for p in plugins: + if p.get('name') == 'sample': + plugin_name = p.get('name').encode('utf-8') + plugin_state = p.get('enabled') + break + else: + return + + with RollbackContext() as rollback: + rollback.prependDefer(set_plugin_state, plugin_name, + plugin_state) + + resp = self.request('/config/plugins/sample') + self.assertEquals(200, resp.status) + + resp = self.request('/config/plugins/sample/enable', + '{}', 'POST') + self.assertEquals(200, resp.status) + + resp = self.request('/config/plugins') + self.assertEquals(200, resp.status) + plugins = json.loads(resp.read()) + + for p in plugins: + if p.get('name') == 'sample': + plugin_state = p.get('enabled') + break + self.assertTrue(plugin_state) + + resp = self.request('/config/plugins/sample/disable', + '{}', 'POST') + self.assertEquals(200, resp.status) + + resp = self.request('/config/plugins') + self.assertEquals(200, resp.status) + plugins = json.loads(resp.read()) + + for p in plugins: + if p.get('name') == 'sample': + plugin_state = p.get('enabled') + break + self.assertFalse(plugin_state) + + def test_plugins_api_404(self): + resp = self.request('/plugins') + self.assertEquals(404, resp.status) + def test_user_log(self): # Login and logout to make sure there there are entries in user log hdrs = {'AUTHORIZATION': '', diff --git a/tests/test_utils.py b/tests/test_utils.py index e7fd264..e63e1a2 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -19,10 +19,14 @@ # License along with this library; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+import mock +import os +import tempfile import unittest
from wok.exception import InvalidParameter -from wok.utils import convert_data_size +from wok.rollbackcontext import RollbackContext +from wok.utils import convert_data_size, set_plugin_state
class UtilsTests(unittest.TestCase): @@ -69,3 +73,72 @@ class UtilsTests(unittest.TestCase):
for d in success_data: self.assertEquals(d['got'], d['want']) + + def _get_fake_config_file_content(self, enable=True): + return """\ +[a_random_section] +# a random section for testing purposes +enable = 1 + +[wok] +# Enable plugin on Wok server (values: True|False) +enable = %s + +[fakeplugin] +# Yet another comment on this config file +enable = 2 +very_interesting_option = True +""" % str(enable) + + def _get_config_file_template(self, enable=True): + return """\ +[a_random_section] +# a random section for testing purposes +enable = 1 + +[wok] +# Enable plugin on Wok server (values: True|False) +enable = %s + +[fakeplugin] +# Yet another comment on this config file +enable = 2 +very_interesting_option = True +""" % str(enable) + + def _create_fake_config_file(self): + _, tmp_file_name = tempfile.mkstemp(suffix='.conf') + + config_contents = self._get_fake_config_file_content() + with open(tmp_file_name, 'w') as f: + f.writelines(config_contents) + + return tmp_file_name + + @mock.patch('wok.utils.get_plugin_config_file') + @mock.patch('wok.utils.update_cherrypy_mounted_tree') + def test_set_plugin_state(self, mock_update_cherrypy, mock_config_file): + mock_update_cherrypy.return_value = True + + with RollbackContext() as rollback: + + config_file_name = self._create_fake_config_file() + rollback.prependDefer(os.remove, config_file_name) + + mock_config_file.return_value = config_file_name + + set_plugin_state('pluginA', False) + with open(config_file_name, 'r') as f: + updated_conf = f.read() + self.assertEqual( + updated_conf, + self._get_config_file_template(enable=False) + ) + + set_plugin_state('pluginA', True) + with open(config_file_name, 'r') as f: + updated_conf = f.read() + self.assertEqual( + updated_conf, + self._get_config_file_template(enable=True) + )
_______________________________________________ Kimchi-devel mailing list Kimchi-devel@ovirt.org http://lists.ovirt.org/mailman/listinfo/kimchi-devel

Aline, I've found problems with this request: " Please, set self.admin_methods = [POST] to restrict enable/disable operations to admin users. Also update test_authorization.py to validate that. Use the sample plugin in the tests. " First problem: there is no test_authorization.py in WoK. I would need to make one similar to what Kimchi has. Not a big deal, just mentioning it here. Second problem: setting admin_methods = ['POST'] is blocking the GET requests too. This is the change I've made in the v4 of the patch: diff --git a/src/wok/control/config.py b/src/wok/control/config.py index 05383c7..a1fdd42 100644 --- a/src/wok/control/config.py +++ b/src/wok/control/config.py @@ -36,7 +36,7 @@ PLUGIN_REQUESTS = { } -@UrlSubNode("config") +@UrlSubNode("config", True) class Config(Resource): def __init__(self, model, id=None): super(Config, self).__init__(model, id) @@ -61,6 +61,7 @@ class Plugin(Resource): def __init__(self, model, ident=None): super(Plugin, self).__init__(model, ident) self.ident = ident + self.admin_methods = ['POST'] self.uri_fmt = "/config/plugins/%s" self.log_map = PLUGIN_REQUESTS self.enable = self.generate_action_handler('enable') With this change, I am unable to get the contents of both /config and /config/plugins without typing username and password. And it kind of makes sense. I haven't looked into the internals perhaps 'admin_methods' means that the 'POST' method will require admin (sudo) privilege and the other http methods will not require a sudo user, but *will require an authentication*. I can assert that this is the behavior right now - using a non-sudo user I an able to retrieve the contents of the /config: [danielhb@arthas wok_all_plugins]$ curl -k -u not_sudo -H "Content-Type: application/json" -H "Accept: application/json" -X GET 'https://localhost:8001/config' -d'{}' Enter host password for user 'not_sudo': { "proxy_port":"8001", "websockets_port":"64667", "version":"2.3.0-72.gitf7effa8", "auth":"pam", "server_root":"" }[danielhb@arthas wok_all_plugins]$ If I do not supply credentials, a 401 html error is returned. Both APIs are being retrieved in the UI without credentials to build WoK login. If I go forward with this change as is, the plug-in icons aren't displayed in the bottom of the login page. Unless I am missing something trivial, I think we'll have to postpone this change until we're certain we're not breaking anything that's currently working. I'll add the 'self.admin_methods = [POST]' line alone, but I'll not turn on the authentication of this controller. I'll postpone the test_authentication change too since it makes little sense to add it with authentication off in the controller. Daniel On 02/03/2017 12:39 PM, Aline Manera wrote:
On 02/03/2017 12:32 PM, Daniel Henrique Barboza wrote:
On 02/03/2017 12:21 PM, Aline Manera wrote:
Hi Daniel,
On 02/01/2017 10:03 AM, dhbarboza82@gmail.com wrote:
From: Daniel Henrique Barboza <danielhb@linux.vnet.ibm.com>
This patch adds a backend for a new API called /config/plugins.
The idea is to be able to retrieve the 'enable' status of WoK plug-ins and also provide a way to enable/disable them. The enable|disable operation consists on two steps:
- changing the 'enable=' attribute of the [WoK] section of the plugin .conf file;
- the plug-in is removed/added in the cherrypy.tree on the fly.
Several changes/enhancements in the backend were made to make this possible, such as:
- added the 'test' parameter in the config.py.in file to make it available for reading in the backend. This parameter indicates whether WoK is running in test mode;
- 'load_plugin' was moved from server.py to utils.py to make it available for utils functions to load plug-ins;
- a new 'depends' attribute is now being considered in the root class of each plug-in. This is an array that indicates all the plug-ins it has a dependency on. For example, Kimchi would mark self.depends = ['gingerbase'] in its root file. The absence of this attribute means that the plug-in does not have any dependency aside from WoK.
Previous /plugins API were removed because it was redundant with this work.
Uni tests included.
Signed-off-by: Daniel Henrique Barboza <danielhb@linux.vnet.ibm.com> --- docs/API/config.md | 32 +++++++ docs/API/plugins.md | 13 --- src/wok/config.py.in | 5 +- src/wok/control/config.py | 31 ++++++- src/wok/control/plugins.py | 29 ------ src/wok/i18n.py | 4 + src/wok/model/plugins.py | 40 ++++++-- src/wok/server.py | 56 ++--------- src/wok/utils.py | 227 +++++++++++++++++++++++++++++++++++++++++++-- tests/test_api.py | 59 ++++++++++++ tests/test_utils.py | 75 ++++++++++++++- 11 files changed, 460 insertions(+), 111 deletions(-) delete mode 100644 docs/API/plugins.md delete mode 100644 src/wok/control/plugins.py
diff --git a/docs/API/config.md b/docs/API/config.md index 4ba455e..87619ac 100644 --- a/docs/API/config.md +++ b/docs/API/config.md @@ -26,3 +26,35 @@ GET /config websockets_port: 64667, version: 2.0 } + +### Collection: Plugins + +**URI:** /config/plugins + +**Methods:** + +* **GET**: Retrieve a summarized list of all UI Plugins. + +#### Examples +GET /plugins +[{'name': 'pluginA', 'enabled': True, "depends":['pluginB'], "is_dependency_of":[]}, + {'name': 'pluginB', 'enabled': False, "depends":[], "is_dependency_of":['pluginA']}] + +### Resource: Plugins + +**URI:** /config/plugins/*:name* + +Represents the current state of a given WoK plug-in. + +**Methods:** + +* **GET**: Retrieve the state of the plug-in. + * name: The name of the plug-in. + * enabled: True if the plug-in is currently enabled in WoK, False otherwise. +
You forgot to add the description to depends and is_dependency_of parameters
v4
+* **POST**: *See Plugin Actions* + +**Actions (POST):** + +* enable: Enable the plug-in in the configuration file. +* disable: Disable the plug-in in the configuration file.
As you are now doing the change on the fly, I'd say to add it to the description action as well.
v4
diff --git a/docs/API/plugins.md b/docs/API/plugins.md deleted file mode 100644 index aaa37b5..0000000 --- a/docs/API/plugins.md +++ /dev/null @@ -1,13 +0,0 @@ -## REST API Specification for Plugins - -### Collection: Plugins - -**URI:** /plugins - -**Methods:** - -* **GET**: Retrieve a summarized list names of all UI Plugins - -#### Examples -GET /plugins -[pluginA, pluginB, pluginC] diff --git a/src/wok/config.py.in b/src/wok/config.py.in index 9573e66..0e46b17 100644 --- a/src/wok/config.py.in +++ b/src/wok/config.py.in @@ -1,7 +1,7 @@ # # Project Wok # -# Copyright IBM Corp, 2015-2016 +# Copyright IBM Corp, 2015-2017 # # Code derived from Project Kimchi # @@ -269,6 +269,7 @@ def _get_config(): config.set("server", "environment", "production") config.set('server', 'max_body_size', '4*1024*1024') config.set("server", "server_root", "")
+ config.set("server", "test", "true")
The default value should be 'false' as by default Wok runs on production mode.
Yeah I've tested with both "true" and "false" there and it turned out that "true" allows for less code changes. Reason is that when running in production mode WoK the option does not exist and the value of this option is set to 'None', even when setting this default to "false".
Maybe set it to None so. 'true' is not a right value IMO
config.add_section("authentication") config.set("authentication", "method", "pam") config.set("authentication", "ldap_server", "") @@ -278,6 +279,8 @@ def _get_config(): config.add_section("logging") config.set("logging", "log_dir", paths.log_dir) config.set("logging", "log_level", DEFAULT_LOG_LEVEL)
+ config.set("logging", "access_log", "") + config.set("logging", "error_log", "")
Seems a rebase issue here. There was a patch to remove those configuration (access_log and error_log)
No it isn't, I've added the options because the command line has them.
That is not true. I did a patch that was applied 'recently' to remove them from command line as they are not present in the config file.
Check da528f461fdf0c82dbf864d5c1309cd9f159a1f0 for details.
Given than the plug-ins use them in the load process I wanted to send the exact same values in the "def get_plugin_config_options()" call.
Why were those options removed? If no plug-in is using those values I think we can safely remove them here too.
The log parameters is only used by Wok to set them on cherrypy. The plugins only use wok_log to get the log instance to use.
config_file = os.path.join(paths.conf_dir, 'wok.conf') if os.path.exists(config_file): diff --git a/src/wok/control/config.py b/src/wok/control/config.py index 419abc0..05383c7 100644 --- a/src/wok/control/config.py +++ b/src/wok/control/config.py @@ -17,7 +17,7 @@ # License along with this library; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
-from wok.control.base import Resource +from wok.control.base import Collection, Resource from wok.control.utils import UrlSubNode
@@ -28,15 +28,44 @@ CONFIG_REQUESTS = { }
+PLUGIN_REQUESTS = { + 'POST': { + 'enable': "WOKPLUGIN0001L", + 'disable': "WOKPLUGIN0002L", + }, +} + + @UrlSubNode("config") class Config(Resource): def __init__(self, model, id=None): super(Config, self).__init__(model, id) self.uri_fmt = '/config/%s' self.admin_methods = ['POST'] + self.plugins = Plugins(self.model) self.log_map = CONFIG_REQUESTS self.reload = self.generate_action_handler('reload')
@property def data(self): return self.info + + +class Plugins(Collection): + def __init__(self, model): + super(Plugins, self).__init__(model) + self.resource = Plugin + + +class Plugin(Resource): + def __init__(self, model, ident=None): + super(Plugin, self).__init__(model, ident) + self.ident = ident + self.uri_fmt = "/config/plugins/%s" + self.log_map = PLUGIN_REQUESTS + self.enable = self.generate_action_handler('enable') + self.disable = self.generate_action_handler('disable') +
Please, set self.admin_methods = [POST] to restrict enable/disable operations to admin users. Also update test_authorization.py to validate that. Use the sample plugin in the tests.
v4
+ @property + def data(self): + return self.info diff --git a/src/wok/control/plugins.py b/src/wok/control/plugins.py deleted file mode 100644 index 57dfa1b..0000000 --- a/src/wok/control/plugins.py +++ /dev/null @@ -1,29 +0,0 @@ -# -# Project Wok -# -# Copyright IBM Corp, 2015-2016 -# -# Code derived from Project Kimchi -# -# This library is free software; you can redistribute it and/or -# modify it under the terms of the GNU Lesser General Public -# License as published by the Free Software Foundation; either -# version 2.1 of the License, or (at your option) any later version. -# -# This library 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 -# Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public -# License along with this library; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - -from wok.control.base import SimpleCollection -from wok.control.utils import UrlSubNode - - -@UrlSubNode("plugins") -class Plugins(SimpleCollection): - def __init__(self, model): - super(Plugins, self).__init__(model) diff --git a/src/wok/i18n.py b/src/wok/i18n.py index 935c9c1..d44c2f6 100644 --- a/src/wok/i18n.py +++ b/src/wok/i18n.py @@ -57,6 +57,8 @@ messages = {
"WOKCONFIG0001I": _("WoK is going to restart. Existing WoK connections will be closed."),
+ "WOKPLUGIN0001E": _("Unable to find plug-in %(name)s"), + # These messages (ending with L) are for user log purposes "WOKASYNC0001L": _("Successfully completed task '%(target_uri)s'"), "WOKASYNC0002L": _("Failed to complete task '%(target_uri)s'"), @@ -65,4 +67,6 @@ messages = { "WOKRES0001L": _("Request made on resource"), "WOKROOT0001L": _("User '%(username)s' login"), "WOKROOT0002L": _("User '%(username)s' logout"), + "WOKPLUGIN0001L": _("Enable plug-in %(ident)s."), + "WOKPLUGIN0002L": _("Disable plug-in %(ident)s."), } diff --git a/src/wok/model/plugins.py b/src/wok/model/plugins.py index 1b8ec5e..1b39e6c 100644 --- a/src/wok/model/plugins.py +++ b/src/wok/model/plugins.py @@ -1,7 +1,7 @@ # # Project Wok # -# Copyright IBM Corp, 2015-2016 +# Copyright IBM Corp, 2015-2017 # # Code derived from Project Kimchi # @@ -19,10 +19,11 @@ # License along with this library; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
-import cherrypy
-from wok.config import get_base_plugin_uri -from wok.utils import get_enabled_plugins +from wok.exception import NotFoundError +from wok.utils import get_all_affected_plugins_by_plugin +from wok.utils import get_plugin_dependencies, get_plugins, load_plugin_conf +from wok.utils import set_plugin_state
class PluginsModel(object): @@ -30,7 +31,30 @@ class PluginsModel(object): pass
def get_list(self): - # Will only return plugins that were loaded correctly by WOK and are - # properly configured in cherrypy - return [plugin for (plugin, config) in get_enabled_plugins() - if get_base_plugin_uri(plugin) in cherrypy.tree.apps.keys()] + return [plugin for (plugin, config) in get_plugins()] + + +class PluginModel(object): + def __init__(self, **kargs): + pass + + def lookup(self, name): + name = name.encode('utf-8') + + plugin_conf = load_plugin_conf(name) + if not plugin_conf: + raise NotFoundError("WOKPLUGIN0001E", {'name': name}) + + depends = get_plugin_dependencies(name) + is_dependency_of = get_all_affected_plugins_by_plugin(name) + + return {"name": name, "enabled": plugin_conf['wok']['enable'], + "depends": depends, "is_dependency_of": is_dependency_of} + + def enable(self, name): + name = name.encode('utf-8') + set_plugin_state(name, True) + + def disable(self, name): + name = name.encode('utf-8') + set_plugin_state(name, False) diff --git a/src/wok/server.py b/src/wok/server.py index 48f455b..9b49c1a 100644 --- a/src/wok/server.py +++ b/src/wok/server.py @@ -1,7 +1,7 @@ # # Project Wok # -# Copyright IBM Corp, 2015-2016 +# Copyright IBM Corp, 2015-2017 # # Code derived from Project Kimchi # @@ -28,14 +28,14 @@ import os from wok import auth from wok import config from wok.config import config as configParser -from wok.config import PluginConfig, WokConfig +from wok.config import WokConfig from wok.control import sub_nodes from wok.model import model from wok.proxy import check_proxy_config from wok.reqlogger import RequestLogger from wok.root import WokRoot from wok.safewatchedfilehandler import SafeWatchedFileHandler -from wok.utils import get_enabled_plugins, import_class +from wok.utils import get_enabled_plugins, load_plugin
LOGGING_LEVEL = {"debug": logging.DEBUG, @@ -153,56 +153,12 @@ class Server(object): self.app = cherrypy.tree.mount(WokRoot(model_instance, dev_env), options.server_root, self.configObj)
- self._load_plugins(options) + self._load_plugins() cherrypy.lib.sessions.init()
- def _load_plugins(self, options): + def _load_plugins(self): for plugin_name, plugin_config in get_enabled_plugins(): - try: - plugin_class = ('plugins.%s.%s' % - (plugin_name, - plugin_name[0].upper() + plugin_name[1:])) - del plugin_config['wok'] - plugin_config.update(PluginConfig(plugin_name)) - except KeyError: - continue - - try: - plugin_app = import_class(plugin_class)(options) - except (ImportError, Exception), e: - cherrypy.log.error_log.error( - "Failed to import plugin %s, " - "error: %s" % (plugin_class, e.message) - ) - continue - - # dynamically extend plugin config with custom data, if provided - get_custom_conf = getattr(plugin_app, "get_custom_conf", None) - if get_custom_conf is not None: - plugin_config.update(get_custom_conf()) - - # dynamically add tools.wokauth.on = True to extra plugin APIs - try: - sub_nodes = import_class('plugins.%s.control.sub_nodes' % - plugin_name) - - urlSubNodes = {} - for ident, node in sub_nodes.items(): - if node.url_auth: - ident = "/%s" % ident - urlSubNodes[ident] = {'tools.wokauth.on': True} - - plugin_config.update(urlSubNodes) - - except ImportError, e: - cherrypy.log.error_log.error( - "Failed to import subnodes for plugin %s, " - "error: %s" % (plugin_class, e.message) - ) - - cherrypy.tree.mount(plugin_app, - config.get_base_plugin_uri(plugin_name), - plugin_config) + load_plugin(plugin_name, plugin_config)
def start(self): # Subscribe to SignalHandler plugin diff --git a/src/wok/utils.py b/src/wok/utils.py index 9a08001..9e6bb8a 100644 --- a/src/wok/utils.py +++ b/src/wok/utils.py @@ -1,7 +1,7 @@ # # Project Wok # -# Copyright IBM Corp, 2015-2016 +# Copyright IBM Corp, 2015-2017 # # Code derived from Project Kimchi # @@ -37,9 +37,11 @@ import xml.etree.ElementTree as ET from cherrypy.lib.reprconf import Parser from datetime import datetime, timedelta from multiprocessing import Process, Queue +from optparse import Values from threading import Timer
-from wok.config import paths, PluginPaths +from wok import config +from wok.config import paths, PluginConfig, PluginPaths from wok.exception import InvalidParameter, TimeoutExpired from wok.stringutils import decode_value
@@ -57,13 +59,21 @@ def is_digit(value): return False
-def _load_plugin_conf(name): +def get_plugin_config_file(name): plugin_conf = PluginPaths(name).conf_file if not os.path.exists(plugin_conf): cherrypy.log.error_log.error("Plugin configuration file %s" " doesn't exist." % plugin_conf) - return + return None + return plugin_conf + + +def load_plugin_conf(name): try: + plugin_conf = get_plugin_config_file(name) + if not plugin_conf: + return None + return Parser().dict_from_file(plugin_conf) except ValueError as e: cherrypy.log.error_log.error("Failed to load plugin " @@ -71,22 +81,223 @@ def _load_plugin_conf(name): (plugin_conf, e.message))
-def get_enabled_plugins(): +def get_plugins(enabled_only=False): plugin_dir = paths.plugins_dir + try: dir_contents = os.listdir(plugin_dir) except OSError: return + + test_mode = config.config.get('server', 'test').lower() == 'true' + for name in dir_contents: if os.path.isdir(os.path.join(plugin_dir, name)): - plugin_config = _load_plugin_conf(name) + if name == 'sample' and not test_mode: + continue + + plugin_config = load_plugin_conf(name) + if not plugin_config: + continue try: - if plugin_config['wok']['enable']: - yield (name, plugin_config) + if plugin_config['wok']['enable'] is None: + continue + + plugin_enabled = plugin_config['wok']['enable'] + if enabled_only and not plugin_enabled: + continue + + yield (name, plugin_config) except (TypeError, KeyError): continue
+def get_enabled_plugins(): + return get_plugins(enabled_only=True) + + +def get_plugin_app_mounted_in_cherrypy(name): + plugin_uri = '/plugins/' + name + return cherrypy.tree.apps.get(plugin_uri, None) + + +def get_plugin_dependencies(name): + app = get_plugin_app_mounted_in_cherrypy(name) + if app is None or not hasattr(app.root, 'depends'): + return [] + return app.root.depends + + +def get_all_plugins_dependent_on(name): + if not cherrypy.tree.apps: + return [] + + dependencies = [] + for plugin, app in cherrypy.tree.apps.iteritems(): + if hasattr(app.root, 'depends') and name in app.root.depends: + dependencies.append(plugin.replace('/plugins/', '')) + + return dependencies + + +def get_all_affected_plugins_by_plugin(name): + dependencies = get_all_plugins_dependent_on(name) + if len(dependencies) == 0: + return [] + + all_affected_plugins = dependencies + for dep in dependencies: + all_affected_plugins += get_all_affected_plugins_by_plugin(dep) + + return all_affected_plugins + + +def disable_plugin(name): + plugin_deps = get_all_affected_plugins_by_plugin(name) + + for dep in set(plugin_deps): + update_plugin_config_file(dep, False) + update_cherrypy_mounted_tree(dep, False) + + update_plugin_config_file(name, False) + update_cherrypy_mounted_tree(name, False) + + +def enable_plugin(name): + update_plugin_config_file(name, True) + update_cherrypy_mounted_tree(name, True) + + plugin_deps = get_plugin_dependencies(name) + + for dep in set(plugin_deps): + enable_plugin(dep) + + +def set_plugin_state(name, state): + if state is False: + disable_plugin(name) + else: + enable_plugin(name) + + +def update_plugin_config_file(name, state): + plugin_conf = get_plugin_config_file(name) + if not plugin_conf: + return + + config_contents = None + + with open(plugin_conf, 'r') as f: + config_contents = f.readlines() + + wok_section_found = False + + pattern = re.compile("^\s*enable\s*=\s*") + + for i in range(0, len(config_contents)): + if config_contents[i] == '[wok]\n': + wok_section_found = True + continue + + if pattern.match(config_contents[i]) and wok_section_found: + config_contents[i] = 'enable = %s\n' % str(state) + break + + with open(plugin_conf, 'w') as f: + f.writelines(config_contents) + + +def load_plugin(plugin_name, plugin_config): + try: + plugin_class = ('plugins.%s.%s' % + (plugin_name, + plugin_name[0].upper() + plugin_name[1:])) + del plugin_config['wok'] + plugin_config.update(PluginConfig(plugin_name)) + except KeyError: + return + + try: + options = get_plugin_config_options() + plugin_app = import_class(plugin_class)(options) + except (ImportError, Exception), e: + cherrypy.log.error_log.error( + "Failed to import plugin %s, " + "error: %s" % (plugin_class, e.message) + ) + return + + # dynamically extend plugin config with custom data, if provided + get_custom_conf = getattr(plugin_app, "get_custom_conf", None) + if get_custom_conf is not None: + plugin_config.update(get_custom_conf()) + + # dynamically add tools.wokauth.on = True to extra plugin APIs + try: + sub_nodes = import_class('plugins.%s.control.sub_nodes' % + plugin_name) + + urlSubNodes = {} + for ident, node in sub_nodes.items(): + if node.url_auth: + ident = "/%s" % ident + urlSubNodes[ident] = {'tools.wokauth.on': True} + + plugin_config.update(urlSubNodes) + + except ImportError, e: + cherrypy.log.error_log.error( + "Failed to import subnodes for plugin %s, " + "error: %s" % (plugin_class, e.message) + ) + + cherrypy.tree.mount(plugin_app, + config.get_base_plugin_uri(plugin_name), + plugin_config) + + +def is_plugin_mounted_in_cherrypy(plugin_uri): + return cherrypy.tree.apps.get(plugin_uri) is not None + + +def update_cherrypy_mounted_tree(plugin, state): + plugin_uri = '/plugin/' + plugin + + if state is False and is_plugin_mounted_in_cherrypy(plugin_uri): + del cherrypy.tree.apps[plugin_uri] + + if state is True and not is_plugin_mounted_in_cherrypy(plugin_uri): + plugin_config = load_plugin_conf(plugin) + load_plugin(plugin, plugin_config) + + +def get_plugin_config_options(): + options = Values() + + options.websockets_port = config.config.getint('server', + 'websockets_port') + options.cherrypy_port = config.config.getint('server', + 'cherrypy_port') + options.proxy_port = config.config.getint('server', 'proxy_port') + options.session_timeout = config.config.getint('server', + 'session_timeout') + + options.test = config.config.get('server', 'test') + if options.test == 'None': + options.test = None + + options.environment = config.config.get('server', 'environment') + options.server_root = config.config.get('server', 'server_root') + options.max_body_size = config.config.get('server', 'max_body_size') + + options.log_dir = config.config.get('logging', 'log_dir') + options.log_level = config.config.get('logging', 'log_level') + options.access_log = config.config.get('logging', 'access_log') + options.error_log = config.config.get('logging', 'error_log') + + return options + + def get_all_tabs(): files = [os.path.join(paths.ui_dir, 'config/tab-ext.xml')]
diff --git a/tests/test_api.py b/tests/test_api.py index 1430bc1..6fbee75 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -26,6 +26,8 @@ import utils from functools import partial
from wok.asynctask import AsyncTask +from wok.utils import set_plugin_state +from wok.rollbackcontext import RollbackContext
test_server = None model = None @@ -54,6 +56,63 @@ class APITests(unittest.TestCase): "server_root"] self.assertEquals(sorted(keys), sorted(conf.keys()))
+ def test_config_plugins(self): + resp = self.request('/config/plugins') + self.assertEquals(200, resp.status) + + plugins = json.loads(resp.read()) + if len(plugins) == 0: + return + + plugin_name = '' + plugin_state = '' + for p in plugins: + if p.get('name') == 'sample': + plugin_name = p.get('name').encode('utf-8') + plugin_state = p.get('enabled') + break + else: + return + + with RollbackContext() as rollback: + rollback.prependDefer(set_plugin_state, plugin_name, + plugin_state) + + resp = self.request('/config/plugins/sample') + self.assertEquals(200, resp.status) + + resp = self.request('/config/plugins/sample/enable', + '{}', 'POST') + self.assertEquals(200, resp.status) + + resp = self.request('/config/plugins') + self.assertEquals(200, resp.status) + plugins = json.loads(resp.read()) + + for p in plugins: + if p.get('name') == 'sample': + plugin_state = p.get('enabled') + break + self.assertTrue(plugin_state) + + resp = self.request('/config/plugins/sample/disable', + '{}', 'POST') + self.assertEquals(200, resp.status) + + resp = self.request('/config/plugins') + self.assertEquals(200, resp.status) + plugins = json.loads(resp.read()) + + for p in plugins: + if p.get('name') == 'sample': + plugin_state = p.get('enabled') + break + self.assertFalse(plugin_state) + + def test_plugins_api_404(self): + resp = self.request('/plugins') + self.assertEquals(404, resp.status) + def test_user_log(self): # Login and logout to make sure there there are entries in user log hdrs = {'AUTHORIZATION': '', diff --git a/tests/test_utils.py b/tests/test_utils.py index e7fd264..e63e1a2 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -19,10 +19,14 @@ # License along with this library; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+import mock +import os +import tempfile import unittest
from wok.exception import InvalidParameter -from wok.utils import convert_data_size +from wok.rollbackcontext import RollbackContext +from wok.utils import convert_data_size, set_plugin_state
class UtilsTests(unittest.TestCase): @@ -69,3 +73,72 @@ class UtilsTests(unittest.TestCase):
for d in success_data: self.assertEquals(d['got'], d['want']) + + def _get_fake_config_file_content(self, enable=True): + return """\ +[a_random_section] +# a random section for testing purposes +enable = 1 + +[wok] +# Enable plugin on Wok server (values: True|False) +enable = %s + +[fakeplugin] +# Yet another comment on this config file +enable = 2 +very_interesting_option = True +""" % str(enable) + + def _get_config_file_template(self, enable=True): + return """\ +[a_random_section] +# a random section for testing purposes +enable = 1 + +[wok] +# Enable plugin on Wok server (values: True|False) +enable = %s + +[fakeplugin] +# Yet another comment on this config file +enable = 2 +very_interesting_option = True +""" % str(enable) + + def _create_fake_config_file(self): + _, tmp_file_name = tempfile.mkstemp(suffix='.conf') + + config_contents = self._get_fake_config_file_content() + with open(tmp_file_name, 'w') as f: + f.writelines(config_contents) + + return tmp_file_name + + @mock.patch('wok.utils.get_plugin_config_file') + @mock.patch('wok.utils.update_cherrypy_mounted_tree') + def test_set_plugin_state(self, mock_update_cherrypy, mock_config_file): + mock_update_cherrypy.return_value = True + + with RollbackContext() as rollback: + + config_file_name = self._create_fake_config_file() + rollback.prependDefer(os.remove, config_file_name) + + mock_config_file.return_value = config_file_name + + set_plugin_state('pluginA', False) + with open(config_file_name, 'r') as f: + updated_conf = f.read() + self.assertEqual( + updated_conf, + self._get_config_file_template(enable=False) + ) + + set_plugin_state('pluginA', True) + with open(config_file_name, 'r') as f: + updated_conf = f.read() + self.assertEqual( + updated_conf, + self._get_config_file_template(enable=True) + )
_______________________________________________ Kimchi-devel mailing list Kimchi-devel@ovirt.org http://lists.ovirt.org/mailman/listinfo/kimchi-devel

Just talked with Aline offline and if we don't set the UrlSubNode to 'True' we have the intended behavior. I'll do that in the v4. Aline also said to use the existing test_api.py for the new tests. On 02/03/2017 03:34 PM, Daniel Henrique Barboza wrote:
Aline, I've found problems with this request:
" Please, set self.admin_methods = [POST] to restrict enable/disable operations to admin users. Also update test_authorization.py to validate that. Use the sample plugin in the tests. "
First problem: there is no test_authorization.py in WoK. I would need to make one similar to what Kimchi has. Not a big deal, just mentioning it here.
Second problem: setting admin_methods = ['POST'] is blocking the GET requests too. This is the change I've made in the v4 of the patch:
diff --git a/src/wok/control/config.py b/src/wok/control/config.py index 05383c7..a1fdd42 100644 --- a/src/wok/control/config.py +++ b/src/wok/control/config.py @@ -36,7 +36,7 @@ PLUGIN_REQUESTS = { }
-@UrlSubNode("config") +@UrlSubNode("config", True) class Config(Resource): def __init__(self, model, id=None): super(Config, self).__init__(model, id) @@ -61,6 +61,7 @@ class Plugin(Resource): def __init__(self, model, ident=None): super(Plugin, self).__init__(model, ident) self.ident = ident + self.admin_methods = ['POST'] self.uri_fmt = "/config/plugins/%s" self.log_map = PLUGIN_REQUESTS self.enable = self.generate_action_handler('enable')
With this change, I am unable to get the contents of both /config and /config/plugins without typing username and password.
And it kind of makes sense. I haven't looked into the internals perhaps 'admin_methods' means that the 'POST' method will require admin (sudo) privilege and the other http methods will not require a sudo user, but *will require an authentication*. I can assert that this is the behavior right now - using a non-sudo user I an able to retrieve the contents of the /config:
[danielhb@arthas wok_all_plugins]$ curl -k -u not_sudo -H "Content-Type: application/json" -H "Accept: application/json" -X GET 'https://localhost:8001/config' -d'{}' Enter host password for user 'not_sudo': { "proxy_port":"8001", "websockets_port":"64667", "version":"2.3.0-72.gitf7effa8", "auth":"pam", "server_root":"" }[danielhb@arthas wok_all_plugins]$
If I do not supply credentials, a 401 html error is returned.
Both APIs are being retrieved in the UI without credentials to build WoK login. If I go forward with this change as is, the plug-in icons aren't displayed in the bottom of the login page.
Unless I am missing something trivial, I think we'll have to postpone this change until we're certain we're not breaking anything that's currently working.
I'll add the 'self.admin_methods = [POST]' line alone, but I'll not turn on the authentication of this controller. I'll postpone the test_authentication change too since it makes little sense to add it with authentication off in the controller.
Daniel
On 02/03/2017 12:39 PM, Aline Manera wrote:
On 02/03/2017 12:32 PM, Daniel Henrique Barboza wrote:
On 02/03/2017 12:21 PM, Aline Manera wrote:
Hi Daniel,
On 02/01/2017 10:03 AM, dhbarboza82@gmail.com wrote:
From: Daniel Henrique Barboza <danielhb@linux.vnet.ibm.com>
This patch adds a backend for a new API called /config/plugins.
The idea is to be able to retrieve the 'enable' status of WoK plug-ins and also provide a way to enable/disable them. The enable|disable operation consists on two steps:
- changing the 'enable=' attribute of the [WoK] section of the plugin .conf file;
- the plug-in is removed/added in the cherrypy.tree on the fly.
Several changes/enhancements in the backend were made to make this possible, such as:
- added the 'test' parameter in the config.py.in file to make it available for reading in the backend. This parameter indicates whether WoK is running in test mode;
- 'load_plugin' was moved from server.py to utils.py to make it available for utils functions to load plug-ins;
- a new 'depends' attribute is now being considered in the root class of each plug-in. This is an array that indicates all the plug-ins it has a dependency on. For example, Kimchi would mark self.depends = ['gingerbase'] in its root file. The absence of this attribute means that the plug-in does not have any dependency aside from WoK.
Previous /plugins API were removed because it was redundant with this work.
Uni tests included.
Signed-off-by: Daniel Henrique Barboza <danielhb@linux.vnet.ibm.com> --- docs/API/config.md | 32 +++++++ docs/API/plugins.md | 13 --- src/wok/config.py.in | 5 +- src/wok/control/config.py | 31 ++++++- src/wok/control/plugins.py | 29 ------ src/wok/i18n.py | 4 + src/wok/model/plugins.py | 40 ++++++-- src/wok/server.py | 56 ++--------- src/wok/utils.py | 227 +++++++++++++++++++++++++++++++++++++++++++-- tests/test_api.py | 59 ++++++++++++ tests/test_utils.py | 75 ++++++++++++++- 11 files changed, 460 insertions(+), 111 deletions(-) delete mode 100644 docs/API/plugins.md delete mode 100644 src/wok/control/plugins.py
diff --git a/docs/API/config.md b/docs/API/config.md index 4ba455e..87619ac 100644 --- a/docs/API/config.md +++ b/docs/API/config.md @@ -26,3 +26,35 @@ GET /config websockets_port: 64667, version: 2.0 } + +### Collection: Plugins + +**URI:** /config/plugins + +**Methods:** + +* **GET**: Retrieve a summarized list of all UI Plugins. + +#### Examples +GET /plugins +[{'name': 'pluginA', 'enabled': True, "depends":['pluginB'], "is_dependency_of":[]}, + {'name': 'pluginB', 'enabled': False, "depends":[], "is_dependency_of":['pluginA']}] + +### Resource: Plugins + +**URI:** /config/plugins/*:name* + +Represents the current state of a given WoK plug-in. + +**Methods:** + +* **GET**: Retrieve the state of the plug-in. + * name: The name of the plug-in. + * enabled: True if the plug-in is currently enabled in WoK, False otherwise. +
You forgot to add the description to depends and is_dependency_of parameters
v4
+* **POST**: *See Plugin Actions* + +**Actions (POST):** + +* enable: Enable the plug-in in the configuration file. +* disable: Disable the plug-in in the configuration file.
As you are now doing the change on the fly, I'd say to add it to the description action as well.
v4
diff --git a/docs/API/plugins.md b/docs/API/plugins.md deleted file mode 100644 index aaa37b5..0000000 --- a/docs/API/plugins.md +++ /dev/null @@ -1,13 +0,0 @@ -## REST API Specification for Plugins - -### Collection: Plugins - -**URI:** /plugins - -**Methods:** - -* **GET**: Retrieve a summarized list names of all UI Plugins - -#### Examples -GET /plugins -[pluginA, pluginB, pluginC] diff --git a/src/wok/config.py.in b/src/wok/config.py.in index 9573e66..0e46b17 100644 --- a/src/wok/config.py.in +++ b/src/wok/config.py.in @@ -1,7 +1,7 @@ # # Project Wok # -# Copyright IBM Corp, 2015-2016 +# Copyright IBM Corp, 2015-2017 # # Code derived from Project Kimchi # @@ -269,6 +269,7 @@ def _get_config(): config.set("server", "environment", "production") config.set('server', 'max_body_size', '4*1024*1024') config.set("server", "server_root", "")
+ config.set("server", "test", "true")
The default value should be 'false' as by default Wok runs on production mode.
Yeah I've tested with both "true" and "false" there and it turned out that "true" allows for less code changes. Reason is that when running in production mode WoK the option does not exist and the value of this option is set to 'None', even when setting this default to "false".
Maybe set it to None so. 'true' is not a right value IMO
config.add_section("authentication") config.set("authentication", "method", "pam") config.set("authentication", "ldap_server", "") @@ -278,6 +279,8 @@ def _get_config(): config.add_section("logging") config.set("logging", "log_dir", paths.log_dir) config.set("logging", "log_level", DEFAULT_LOG_LEVEL)
+ config.set("logging", "access_log", "") + config.set("logging", "error_log", "")
Seems a rebase issue here. There was a patch to remove those configuration (access_log and error_log)
No it isn't, I've added the options because the command line has them.
That is not true. I did a patch that was applied 'recently' to remove them from command line as they are not present in the config file.
Check da528f461fdf0c82dbf864d5c1309cd9f159a1f0 for details.
Given than the plug-ins use them in the load process I wanted to send the exact same values in the "def get_plugin_config_options()" call.
Why were those options removed? If no plug-in is using those values I think we can safely remove them here too.
The log parameters is only used by Wok to set them on cherrypy. The plugins only use wok_log to get the log instance to use.
config_file = os.path.join(paths.conf_dir, 'wok.conf') if os.path.exists(config_file): diff --git a/src/wok/control/config.py b/src/wok/control/config.py index 419abc0..05383c7 100644 --- a/src/wok/control/config.py +++ b/src/wok/control/config.py @@ -17,7 +17,7 @@ # License along with this library; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
-from wok.control.base import Resource +from wok.control.base import Collection, Resource from wok.control.utils import UrlSubNode
@@ -28,15 +28,44 @@ CONFIG_REQUESTS = { }
+PLUGIN_REQUESTS = { + 'POST': { + 'enable': "WOKPLUGIN0001L", + 'disable': "WOKPLUGIN0002L", + }, +} + + @UrlSubNode("config") class Config(Resource): def __init__(self, model, id=None): super(Config, self).__init__(model, id) self.uri_fmt = '/config/%s' self.admin_methods = ['POST'] + self.plugins = Plugins(self.model) self.log_map = CONFIG_REQUESTS self.reload = self.generate_action_handler('reload')
@property def data(self): return self.info + + +class Plugins(Collection): + def __init__(self, model): + super(Plugins, self).__init__(model) + self.resource = Plugin + + +class Plugin(Resource): + def __init__(self, model, ident=None): + super(Plugin, self).__init__(model, ident) + self.ident = ident + self.uri_fmt = "/config/plugins/%s" + self.log_map = PLUGIN_REQUESTS + self.enable = self.generate_action_handler('enable') + self.disable = self.generate_action_handler('disable') +
Please, set self.admin_methods = [POST] to restrict enable/disable operations to admin users. Also update test_authorization.py to validate that. Use the sample plugin in the tests.
v4
+ @property + def data(self): + return self.info diff --git a/src/wok/control/plugins.py b/src/wok/control/plugins.py deleted file mode 100644 index 57dfa1b..0000000 --- a/src/wok/control/plugins.py +++ /dev/null @@ -1,29 +0,0 @@ -# -# Project Wok -# -# Copyright IBM Corp, 2015-2016 -# -# Code derived from Project Kimchi -# -# This library is free software; you can redistribute it and/or -# modify it under the terms of the GNU Lesser General Public -# License as published by the Free Software Foundation; either -# version 2.1 of the License, or (at your option) any later version. -# -# This library 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 -# Lesser General Public License for more details. -# -# You should have received a copy of the GNU Lesser General Public -# License along with this library; if not, write to the Free Software -# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA - -from wok.control.base import SimpleCollection -from wok.control.utils import UrlSubNode - - -@UrlSubNode("plugins") -class Plugins(SimpleCollection): - def __init__(self, model): - super(Plugins, self).__init__(model) diff --git a/src/wok/i18n.py b/src/wok/i18n.py index 935c9c1..d44c2f6 100644 --- a/src/wok/i18n.py +++ b/src/wok/i18n.py @@ -57,6 +57,8 @@ messages = {
"WOKCONFIG0001I": _("WoK is going to restart. Existing WoK connections will be closed."),
+ "WOKPLUGIN0001E": _("Unable to find plug-in %(name)s"), + # These messages (ending with L) are for user log purposes "WOKASYNC0001L": _("Successfully completed task '%(target_uri)s'"), "WOKASYNC0002L": _("Failed to complete task '%(target_uri)s'"), @@ -65,4 +67,6 @@ messages = { "WOKRES0001L": _("Request made on resource"), "WOKROOT0001L": _("User '%(username)s' login"), "WOKROOT0002L": _("User '%(username)s' logout"), + "WOKPLUGIN0001L": _("Enable plug-in %(ident)s."), + "WOKPLUGIN0002L": _("Disable plug-in %(ident)s."), } diff --git a/src/wok/model/plugins.py b/src/wok/model/plugins.py index 1b8ec5e..1b39e6c 100644 --- a/src/wok/model/plugins.py +++ b/src/wok/model/plugins.py @@ -1,7 +1,7 @@ # # Project Wok # -# Copyright IBM Corp, 2015-2016 +# Copyright IBM Corp, 2015-2017 # # Code derived from Project Kimchi # @@ -19,10 +19,11 @@ # License along with this library; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
-import cherrypy
-from wok.config import get_base_plugin_uri -from wok.utils import get_enabled_plugins +from wok.exception import NotFoundError +from wok.utils import get_all_affected_plugins_by_plugin +from wok.utils import get_plugin_dependencies, get_plugins, load_plugin_conf +from wok.utils import set_plugin_state
class PluginsModel(object): @@ -30,7 +31,30 @@ class PluginsModel(object): pass
def get_list(self): - # Will only return plugins that were loaded correctly by WOK and are - # properly configured in cherrypy - return [plugin for (plugin, config) in get_enabled_plugins() - if get_base_plugin_uri(plugin) in cherrypy.tree.apps.keys()] + return [plugin for (plugin, config) in get_plugins()] + + +class PluginModel(object): + def __init__(self, **kargs): + pass + + def lookup(self, name): + name = name.encode('utf-8') + + plugin_conf = load_plugin_conf(name) + if not plugin_conf: + raise NotFoundError("WOKPLUGIN0001E", {'name': name}) + + depends = get_plugin_dependencies(name) + is_dependency_of = get_all_affected_plugins_by_plugin(name) + + return {"name": name, "enabled": plugin_conf['wok']['enable'], + "depends": depends, "is_dependency_of": is_dependency_of} + + def enable(self, name): + name = name.encode('utf-8') + set_plugin_state(name, True) + + def disable(self, name): + name = name.encode('utf-8') + set_plugin_state(name, False) diff --git a/src/wok/server.py b/src/wok/server.py index 48f455b..9b49c1a 100644 --- a/src/wok/server.py +++ b/src/wok/server.py @@ -1,7 +1,7 @@ # # Project Wok # -# Copyright IBM Corp, 2015-2016 +# Copyright IBM Corp, 2015-2017 # # Code derived from Project Kimchi # @@ -28,14 +28,14 @@ import os from wok import auth from wok import config from wok.config import config as configParser -from wok.config import PluginConfig, WokConfig +from wok.config import WokConfig from wok.control import sub_nodes from wok.model import model from wok.proxy import check_proxy_config from wok.reqlogger import RequestLogger from wok.root import WokRoot from wok.safewatchedfilehandler import SafeWatchedFileHandler -from wok.utils import get_enabled_plugins, import_class +from wok.utils import get_enabled_plugins, load_plugin
LOGGING_LEVEL = {"debug": logging.DEBUG, @@ -153,56 +153,12 @@ class Server(object): self.app = cherrypy.tree.mount(WokRoot(model_instance, dev_env), options.server_root, self.configObj)
- self._load_plugins(options) + self._load_plugins() cherrypy.lib.sessions.init()
- def _load_plugins(self, options): + def _load_plugins(self): for plugin_name, plugin_config in get_enabled_plugins(): - try: - plugin_class = ('plugins.%s.%s' % - (plugin_name, - plugin_name[0].upper() + plugin_name[1:])) - del plugin_config['wok'] - plugin_config.update(PluginConfig(plugin_name)) - except KeyError: - continue - - try: - plugin_app = import_class(plugin_class)(options) - except (ImportError, Exception), e: - cherrypy.log.error_log.error( - "Failed to import plugin %s, " - "error: %s" % (plugin_class, e.message) - ) - continue - - # dynamically extend plugin config with custom data, if provided - get_custom_conf = getattr(plugin_app, "get_custom_conf", None) - if get_custom_conf is not None: - plugin_config.update(get_custom_conf()) - - # dynamically add tools.wokauth.on = True to extra plugin APIs - try: - sub_nodes = import_class('plugins.%s.control.sub_nodes' % - plugin_name) - - urlSubNodes = {} - for ident, node in sub_nodes.items(): - if node.url_auth: - ident = "/%s" % ident - urlSubNodes[ident] = {'tools.wokauth.on': True} - - plugin_config.update(urlSubNodes) - - except ImportError, e: - cherrypy.log.error_log.error( - "Failed to import subnodes for plugin %s, " - "error: %s" % (plugin_class, e.message) - ) - - cherrypy.tree.mount(plugin_app, - config.get_base_plugin_uri(plugin_name), - plugin_config) + load_plugin(plugin_name, plugin_config)
def start(self): # Subscribe to SignalHandler plugin diff --git a/src/wok/utils.py b/src/wok/utils.py index 9a08001..9e6bb8a 100644 --- a/src/wok/utils.py +++ b/src/wok/utils.py @@ -1,7 +1,7 @@ # # Project Wok # -# Copyright IBM Corp, 2015-2016 +# Copyright IBM Corp, 2015-2017 # # Code derived from Project Kimchi # @@ -37,9 +37,11 @@ import xml.etree.ElementTree as ET from cherrypy.lib.reprconf import Parser from datetime import datetime, timedelta from multiprocessing import Process, Queue +from optparse import Values from threading import Timer
-from wok.config import paths, PluginPaths +from wok import config +from wok.config import paths, PluginConfig, PluginPaths from wok.exception import InvalidParameter, TimeoutExpired from wok.stringutils import decode_value
@@ -57,13 +59,21 @@ def is_digit(value): return False
-def _load_plugin_conf(name): +def get_plugin_config_file(name): plugin_conf = PluginPaths(name).conf_file if not os.path.exists(plugin_conf): cherrypy.log.error_log.error("Plugin configuration file %s" " doesn't exist." % plugin_conf) - return + return None + return plugin_conf + + +def load_plugin_conf(name): try: + plugin_conf = get_plugin_config_file(name) + if not plugin_conf: + return None + return Parser().dict_from_file(plugin_conf) except ValueError as e: cherrypy.log.error_log.error("Failed to load plugin " @@ -71,22 +81,223 @@ def _load_plugin_conf(name): (plugin_conf, e.message))
-def get_enabled_plugins(): +def get_plugins(enabled_only=False): plugin_dir = paths.plugins_dir + try: dir_contents = os.listdir(plugin_dir) except OSError: return + + test_mode = config.config.get('server', 'test').lower() == 'true' + for name in dir_contents: if os.path.isdir(os.path.join(plugin_dir, name)): - plugin_config = _load_plugin_conf(name) + if name == 'sample' and not test_mode: + continue + + plugin_config = load_plugin_conf(name) + if not plugin_config: + continue try: - if plugin_config['wok']['enable']: - yield (name, plugin_config) + if plugin_config['wok']['enable'] is None: + continue + + plugin_enabled = plugin_config['wok']['enable'] + if enabled_only and not plugin_enabled: + continue + + yield (name, plugin_config) except (TypeError, KeyError): continue
+def get_enabled_plugins(): + return get_plugins(enabled_only=True) + + +def get_plugin_app_mounted_in_cherrypy(name): + plugin_uri = '/plugins/' + name + return cherrypy.tree.apps.get(plugin_uri, None) + + +def get_plugin_dependencies(name): + app = get_plugin_app_mounted_in_cherrypy(name) + if app is None or not hasattr(app.root, 'depends'): + return [] + return app.root.depends + + +def get_all_plugins_dependent_on(name): + if not cherrypy.tree.apps: + return [] + + dependencies = [] + for plugin, app in cherrypy.tree.apps.iteritems(): + if hasattr(app.root, 'depends') and name in app.root.depends: + dependencies.append(plugin.replace('/plugins/', '')) + + return dependencies + + +def get_all_affected_plugins_by_plugin(name): + dependencies = get_all_plugins_dependent_on(name) + if len(dependencies) == 0: + return [] + + all_affected_plugins = dependencies + for dep in dependencies: + all_affected_plugins += get_all_affected_plugins_by_plugin(dep) + + return all_affected_plugins + + +def disable_plugin(name): + plugin_deps = get_all_affected_plugins_by_plugin(name) + + for dep in set(plugin_deps): + update_plugin_config_file(dep, False) + update_cherrypy_mounted_tree(dep, False) + + update_plugin_config_file(name, False) + update_cherrypy_mounted_tree(name, False) + + +def enable_plugin(name): + update_plugin_config_file(name, True) + update_cherrypy_mounted_tree(name, True) + + plugin_deps = get_plugin_dependencies(name) + + for dep in set(plugin_deps): + enable_plugin(dep) + + +def set_plugin_state(name, state): + if state is False: + disable_plugin(name) + else: + enable_plugin(name) + + +def update_plugin_config_file(name, state): + plugin_conf = get_plugin_config_file(name) + if not plugin_conf: + return + + config_contents = None + + with open(plugin_conf, 'r') as f: + config_contents = f.readlines() + + wok_section_found = False + + pattern = re.compile("^\s*enable\s*=\s*") + + for i in range(0, len(config_contents)): + if config_contents[i] == '[wok]\n': + wok_section_found = True + continue + + if pattern.match(config_contents[i]) and wok_section_found: + config_contents[i] = 'enable = %s\n' % str(state) + break + + with open(plugin_conf, 'w') as f: + f.writelines(config_contents) + + +def load_plugin(plugin_name, plugin_config): + try: + plugin_class = ('plugins.%s.%s' % + (plugin_name, + plugin_name[0].upper() + plugin_name[1:])) + del plugin_config['wok'] + plugin_config.update(PluginConfig(plugin_name)) + except KeyError: + return + + try: + options = get_plugin_config_options() + plugin_app = import_class(plugin_class)(options) + except (ImportError, Exception), e: + cherrypy.log.error_log.error( + "Failed to import plugin %s, " + "error: %s" % (plugin_class, e.message) + ) + return + + # dynamically extend plugin config with custom data, if provided + get_custom_conf = getattr(plugin_app, "get_custom_conf", None) + if get_custom_conf is not None: + plugin_config.update(get_custom_conf()) + + # dynamically add tools.wokauth.on = True to extra plugin APIs + try: + sub_nodes = import_class('plugins.%s.control.sub_nodes' % + plugin_name) + + urlSubNodes = {} + for ident, node in sub_nodes.items(): + if node.url_auth: + ident = "/%s" % ident + urlSubNodes[ident] = {'tools.wokauth.on': True} + + plugin_config.update(urlSubNodes) + + except ImportError, e: + cherrypy.log.error_log.error( + "Failed to import subnodes for plugin %s, " + "error: %s" % (plugin_class, e.message) + ) + + cherrypy.tree.mount(plugin_app, + config.get_base_plugin_uri(plugin_name), + plugin_config) + + +def is_plugin_mounted_in_cherrypy(plugin_uri): + return cherrypy.tree.apps.get(plugin_uri) is not None + + +def update_cherrypy_mounted_tree(plugin, state): + plugin_uri = '/plugin/' + plugin + + if state is False and is_plugin_mounted_in_cherrypy(plugin_uri): + del cherrypy.tree.apps[plugin_uri] + + if state is True and not is_plugin_mounted_in_cherrypy(plugin_uri): + plugin_config = load_plugin_conf(plugin) + load_plugin(plugin, plugin_config) + + +def get_plugin_config_options(): + options = Values() + + options.websockets_port = config.config.getint('server', + 'websockets_port') + options.cherrypy_port = config.config.getint('server', + 'cherrypy_port') + options.proxy_port = config.config.getint('server', 'proxy_port') + options.session_timeout = config.config.getint('server', + 'session_timeout') + + options.test = config.config.get('server', 'test') + if options.test == 'None': + options.test = None + + options.environment = config.config.get('server', 'environment') + options.server_root = config.config.get('server', 'server_root') + options.max_body_size = config.config.get('server', 'max_body_size') + + options.log_dir = config.config.get('logging', 'log_dir') + options.log_level = config.config.get('logging', 'log_level') + options.access_log = config.config.get('logging', 'access_log') + options.error_log = config.config.get('logging', 'error_log') + + return options + + def get_all_tabs(): files = [os.path.join(paths.ui_dir, 'config/tab-ext.xml')]
diff --git a/tests/test_api.py b/tests/test_api.py index 1430bc1..6fbee75 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -26,6 +26,8 @@ import utils from functools import partial
from wok.asynctask import AsyncTask +from wok.utils import set_plugin_state +from wok.rollbackcontext import RollbackContext
test_server = None model = None @@ -54,6 +56,63 @@ class APITests(unittest.TestCase): "server_root"] self.assertEquals(sorted(keys), sorted(conf.keys()))
+ def test_config_plugins(self): + resp = self.request('/config/plugins') + self.assertEquals(200, resp.status) + + plugins = json.loads(resp.read()) + if len(plugins) == 0: + return + + plugin_name = '' + plugin_state = '' + for p in plugins: + if p.get('name') == 'sample': + plugin_name = p.get('name').encode('utf-8') + plugin_state = p.get('enabled') + break + else: + return + + with RollbackContext() as rollback: + rollback.prependDefer(set_plugin_state, plugin_name, + plugin_state) + + resp = self.request('/config/plugins/sample') + self.assertEquals(200, resp.status) + + resp = self.request('/config/plugins/sample/enable', + '{}', 'POST') + self.assertEquals(200, resp.status) + + resp = self.request('/config/plugins') + self.assertEquals(200, resp.status) + plugins = json.loads(resp.read()) + + for p in plugins: + if p.get('name') == 'sample': + plugin_state = p.get('enabled') + break + self.assertTrue(plugin_state) + + resp = self.request('/config/plugins/sample/disable', + '{}', 'POST') + self.assertEquals(200, resp.status) + + resp = self.request('/config/plugins') + self.assertEquals(200, resp.status) + plugins = json.loads(resp.read()) + + for p in plugins: + if p.get('name') == 'sample': + plugin_state = p.get('enabled') + break + self.assertFalse(plugin_state) + + def test_plugins_api_404(self): + resp = self.request('/plugins') + self.assertEquals(404, resp.status) + def test_user_log(self): # Login and logout to make sure there there are entries in user log hdrs = {'AUTHORIZATION': '', diff --git a/tests/test_utils.py b/tests/test_utils.py index e7fd264..e63e1a2 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -19,10 +19,14 @@ # License along with this library; if not, write to the Free Software # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
+import mock +import os +import tempfile import unittest
from wok.exception import InvalidParameter -from wok.utils import convert_data_size +from wok.rollbackcontext import RollbackContext +from wok.utils import convert_data_size, set_plugin_state
class UtilsTests(unittest.TestCase): @@ -69,3 +73,72 @@ class UtilsTests(unittest.TestCase):
for d in success_data: self.assertEquals(d['got'], d['want']) + + def _get_fake_config_file_content(self, enable=True): + return """\ +[a_random_section] +# a random section for testing purposes +enable = 1 + +[wok] +# Enable plugin on Wok server (values: True|False) +enable = %s + +[fakeplugin] +# Yet another comment on this config file +enable = 2 +very_interesting_option = True +""" % str(enable) + + def _get_config_file_template(self, enable=True): + return """\ +[a_random_section] +# a random section for testing purposes +enable = 1 + +[wok] +# Enable plugin on Wok server (values: True|False) +enable = %s + +[fakeplugin] +# Yet another comment on this config file +enable = 2 +very_interesting_option = True +""" % str(enable) + + def _create_fake_config_file(self): + _, tmp_file_name = tempfile.mkstemp(suffix='.conf') + + config_contents = self._get_fake_config_file_content() + with open(tmp_file_name, 'w') as f: + f.writelines(config_contents) + + return tmp_file_name + + @mock.patch('wok.utils.get_plugin_config_file') + @mock.patch('wok.utils.update_cherrypy_mounted_tree') + def test_set_plugin_state(self, mock_update_cherrypy, mock_config_file): + mock_update_cherrypy.return_value = True + + with RollbackContext() as rollback: + + config_file_name = self._create_fake_config_file() + rollback.prependDefer(os.remove, config_file_name) + + mock_config_file.return_value = config_file_name + + set_plugin_state('pluginA', False) + with open(config_file_name, 'r') as f: + updated_conf = f.read() + self.assertEqual( + updated_conf, + self._get_config_file_template(enable=False) + ) + + set_plugin_state('pluginA', True) + with open(config_file_name, 'r') as f: + updated_conf = f.read() + self.assertEqual( + updated_conf, + self._get_config_file_template(enable=True) + )
_______________________________________________ Kimchi-devel mailing list Kimchi-devel@ovirt.org http://lists.ovirt.org/mailman/listinfo/kimchi-devel

From: Daniel Henrique Barboza <danielhb@linux.vnet.ibm.com> This patch changes ui/js/src/wok.api.js 'listPlugins' method to use the URL /config/plugins instead of /plugins. With this change, ui/js/src/wok.logos.js and ui/js/src/wok.main.js were also changed to handle the different return value from the /config/plugins API. Signed-off-by: Daniel Henrique Barboza <danielhb@linux.vnet.ibm.com> --- ui/js/src/wok.api.js | 4 ++-- ui/js/src/wok.logos.js | 11 +++++++---- ui/js/src/wok.main.js | 10 +++++++--- 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/ui/js/src/wok.api.js b/ui/js/src/wok.api.js index e2829ab..c465dfb 100644 --- a/ui/js/src/wok.api.js +++ b/ui/js/src/wok.api.js @@ -1,7 +1,7 @@ /* * Project Wok * - * Copyright IBM Corp, 2015-2016 + * Copyright IBM Corp, 2015-2017 * * Code derived from Project Kimchi * @@ -111,7 +111,7 @@ var wok = { listPlugins : function(suc, err, sync) { wok.requestJSON({ - url : 'plugins', + url : '/config/plugins', type : 'GET', contentType : 'application/json', dataType : 'json', diff --git a/ui/js/src/wok.logos.js b/ui/js/src/wok.logos.js index a825108..b4e2d75 100644 --- a/ui/js/src/wok.logos.js +++ b/ui/js/src/wok.logos.js @@ -1,7 +1,7 @@ /* * Project Wok * - * Copyright IBM Corp, 2016 + * Copyright IBM Corp, 2016-2017 * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -78,18 +78,21 @@ wok.logos = function(element, powered) { wok.listPlugins(function(plugins) { if(plugins && plugins.length > 0) { $(plugins).each(function(i, p) { + if (p.enabled === false) { + return true; + } var url = wok.substitute(pluginUrl, { - plugin: p + plugin: p.name }); obj[i] = { - name : p + name : p.name } var pluginVersions; pluginVersions = retrieveVersion(url); if(pluginVersions && pluginVersions.length > 0){ obj[i].version = pluginVersions; } - var imagepath = url+'/images/'+p; + var imagepath = url+'/images/'+p.name; if(checkImage(imagepath+'.svg') == 200) { obj[i].image = imagepath+'.svg'; } diff --git a/ui/js/src/wok.main.js b/ui/js/src/wok.main.js index c67e97c..6de8ea0 100644 --- a/ui/js/src/wok.main.js +++ b/ui/js/src/wok.main.js @@ -142,16 +142,20 @@ wok.main = function() { var tabs = retrieveTabs('wok', wokConfigUrl); wok.listPlugins(function(plugins) { $(plugins).each(function(i, p) { + if (p.enabled === false) { + return true; + } + var url = wok.substitute(pluginConfigUrl, { - plugin: p + plugin: p.name }); var i18nUrl = wok.substitute(pluginI18nUrl, { - plugin: p + plugin: p.name }); wok.getI18n(function(i18nObj){ $.extend(i18n, i18nObj)}, function(i18nObj){ //i18n is not define by plugin }, i18nUrl, true); - var pluginTabs = retrieveTabs(p, url); + var pluginTabs = retrieveTabs(p.name, url); if(pluginTabs.length > 0){ tabs.push.apply(tabs, pluginTabs); } -- 2.9.3
participants (3)
-
Aline Manera
-
Daniel Henrique Barboza
-
dhbarboza82@gmail.com