From 31b0fe39b617cc997bed9d56d3aa680f85b18a14 Mon Sep 17 00:00:00 2001 From: Peter Stachowski Date: Mon, 18 Apr 2016 11:34:33 -0400 Subject: [PATCH] Add New Relic License module driver The recent addition of module support in Trove (see https://blueprints.launchpad.net/trove/+spec/module-management ) does not include the New Relic license module driver. This has been added. A decorator to streamline writing drivers (by handling common errors) was also added, and the ping driver modified to use it as well. Since this code is dependent on having an image with New Relic installed, no changes were made to the scenario tests with respect to this new driver. An addition flag was added to the 'apply' interface that passes in whether a module was created with 'admin options.' This allows some rudimentary access control to be implemented. Depends-On: I6fb23b3dbbec98de9ee1e2731bcfc56ab3c0ca42 Change-Id: I282cf533c99e351d23f3b86aae727ae4bf279b64 Closes-Bug: #1571711 --- ...relic-license-driver-0f314edabb7561c4.yaml | 6 + setup.cfg | 1 + trove/common/cfg.py | 2 +- trove/common/exception.py | 5 + .../module/drivers/module_driver.py | 156 ++++++++++++++++-- .../drivers/new_relic_license_driver.py | 95 +++++++++++ .../guestagent/module/drivers/ping_driver.py | 39 ++--- trove/guestagent/module/module_manager.py | 19 ++- .../tests/scenario/runners/module_runners.py | 10 +- 9 files changed, 286 insertions(+), 47 deletions(-) create mode 100644 releasenotes/notes/add-new-relic-license-driver-0f314edabb7561c4.yaml create mode 100644 trove/guestagent/module/drivers/new_relic_license_driver.py diff --git a/releasenotes/notes/add-new-relic-license-driver-0f314edabb7561c4.yaml b/releasenotes/notes/add-new-relic-license-driver-0f314edabb7561c4.yaml new file mode 100644 index 0000000000..2edbd4844e --- /dev/null +++ b/releasenotes/notes/add-new-relic-license-driver-0f314edabb7561c4.yaml @@ -0,0 +1,6 @@ +--- +features: + - Added a module driver for New Relics licenses. + This allows activation of any New Relic software + that is installed on the image. Bug 1571711 + diff --git a/setup.cfg b/setup.cfg index 0fa6f2ae01..f5903213e7 100644 --- a/setup.cfg +++ b/setup.cfg @@ -38,6 +38,7 @@ trove.api.extensions = trove.guestagent.module.drivers = ping = trove.guestagent.module.drivers.ping_driver:PingDriver + new_relic_license = trove.guestagent.module.drivers.new_relic_license_driver:NewRelicLicenseDriver # These are for backwards compatibility with Havana notification_driver configuration values oslo.messaging.notify.drivers = diff --git a/trove/common/cfg.py b/trove/common/cfg.py index 71b8fc1af2..6cdbe06ac6 100644 --- a/trove/common/cfg.py +++ b/trove/common/cfg.py @@ -402,7 +402,7 @@ common_opts = [ 'become alive.'), cfg.StrOpt('module_aes_cbc_key', default='module_aes_cbc_key', help='OpenSSL aes_cbc key for module encryption.'), - cfg.ListOpt('module_types', default=['ping'], + cfg.ListOpt('module_types', default=['ping', 'new_relic_license'], help='A list of module types supported. A module type ' 'corresponds to the name of a ModuleDriver.'), cfg.StrOpt('guest_log_container_name', diff --git a/trove/common/exception.py b/trove/common/exception.py index 8b624552dd..6c31761c21 100644 --- a/trove/common/exception.py +++ b/trove/common/exception.py @@ -522,6 +522,11 @@ class ModuleAccessForbidden(Forbidden): "options. %(options)s") +class ModuleInvalid(Forbidden): + + message = _("The module you are applying is invalid: %(reason)s") + + class ClusterNotFound(NotFound): message = _("Cluster '%(cluster)s' cannot be found.") diff --git a/trove/guestagent/module/drivers/module_driver.py b/trove/guestagent/module/drivers/module_driver.py index 9a1ad58d67..7bf7c305a6 100644 --- a/trove/guestagent/module/drivers/module_driver.py +++ b/trove/guestagent/module/drivers/module_driver.py @@ -15,9 +15,13 @@ # import abc +import functools +import re import six from trove.common import cfg +from trove.common import exception +from trove.common.i18n import _ CONF = cfg.CONF @@ -28,21 +32,64 @@ class ModuleDriver(object): """Base class that defines the contract for module drivers. Note that you don't have to derive from this class to have a valid - driver; it is purely a convenience. + driver; it is purely a convenience. Any class that adheres to the + 'interface' as dictated by this class' abstractmethod decorators + (and other methods such as get_type, get_name and configure) + will work. """ + def __init__(self): + super(ModuleDriver, self).__init__() + + # This is used to store any message args to be substituted by + # the output decorator when logging/returning messages. + self._module_message_args = {} + self._message_args = None + self._generated_name = None + + @property + def message_args(self): + """Return a dict of message args that can be used to enhance + the output decorator messages. This shouldn't be overridden; use + self.message_args = instead to append values. + """ + if not self._message_args: + self._message_args = { + 'name': self.get_name(), + 'type': self.get_type()} + self._message_args.update(self._module_message_args) + return self._message_args + + @message_args.setter + def message_args(self, values): + """Set the message args that can be used to enhance + the output decorator messages. + """ + values = values or {} + self._module_message_args = values + self._message_args = None + + @property + def generated_name(self): + if not self._generated_name: + # Turn class name into 'module type' format. + # For example: DoCustomWorkDriver -> do_custom_work + temp = re.sub('(.)[Dd]river$', r'\1', self.__class__.__name__) + temp2 = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', temp) + temp3 = re.sub('([a-z0-9])([A-Z])', r'\1_\2', temp2) + self._generated_name = temp3.lower() + return self._generated_name def get_type(self): """This is used when setting up a module in Trove, and is here for - code clarity. It just returns the name of the driver. + code clarity. It just returns the name of the driver by default. """ return self.get_name() def get_name(self): - """Attempt to generate a usable name based on the class name. If + """Use the generated name based on the class name. If overridden, must be in lower-case. """ - return self.__class__.__name__.lower().replace( - 'driver', '').replace(' ', '_') + return self.generated_name @abc.abstractmethod def get_description(self): @@ -55,15 +102,104 @@ class ModuleDriver(object): pass @abc.abstractmethod - def apply(self, name, datastore, ds_version, data_file): - """Apply the data to the guest instance. Return status and message - as a tupple. + def apply(self, name, datastore, ds_version, data_file, admin_module): + """Apply the module to the guest instance. Return status and message + as a tuple. Passes in whether the module was created with 'admin' + privileges. This can be used as a form of access control by having + the driver refuse to apply a module if it wasn't created with options + that indicate that it was done by an 'admin' user. """ return False, "Not a concrete driver" @abc.abstractmethod def remove(self, name, datastore, ds_version, data_file): - """Remove the data from the guest instance. Return status and message - as a tupple. + """Remove the module from the guest instance. Return + status and message as a tuple. """ return False, "Not a concrete driver" + + def configure(self, name, datastore, ds_version, data_file): + """Configure the driver. This is particularly useful for adding values + to message_args, by having a line such as: self.message_args = . + These values will be appended to the default ones defined + in the message_args @property. + """ + pass + + +def output(log_message=None, success_message=None, + fail_message=None): + """This is a decorator to trap the typical exceptions that occur + when applying and removing modules. It returns the proper output + corresponding to the error messages automatically. If the function + returns output (success_flag, message) then those are returned, + otherwise success is assumed and the success_message returned. + Using this removes a lot of potential boiler-plate code, however + it is not necessary. + Keyword arguments can be used in the message string. Default + values can be found in the message_args @property, however a + driver can add whatever it see fit, by setting message_args + to a dict in the configure call (see above). Thus if you set + self.message_args = {'my_key': 'my_key_val'} then the message + string could look like "My key is '$(my_key)s'". + """ + success_message = success_message or "Success" + fail_message = fail_message or "Fail" + + def output_decorator(func): + """This is the actual decorator.""" + + @functools.wraps(func) + def wrapper(*args, **kwargs): + """Here's where we handle the error messages and return values + from the actual function. + """ + log_msg = log_message + success_msg = success_message + fail_msg = fail_message + if isinstance(args[0], ModuleDriver): + # Try and insert any message args if they exist in the driver + message_args = args[0].message_args + if message_args: + try: + log_msg = log_msg % message_args + success_msg = success_msg % message_args + fail_msg = fail_msg % message_args + except Exception: + # if there's a problem, just log it and drive on + LOG.warning(_("Could not apply message args: %s") % + message_args) + pass + + if log_msg: + LOG.info(log_msg) + success = False + try: + rv = func(*args, **kwargs) + if rv: + # Use the actual values, if there are some + success, message = rv + else: + success = True + message = success_msg + except exception.ProcessExecutionError as ex: + message = (_("%(msg)s: %(out)s\n%(err)s") % + {'msg': fail_msg, + 'out': ex.stdout, + 'err': ex.stderr}) + message = message.replace(': \n', ': ') + message = message.rstrip() + LOG.exception(message) + except exception.TroveError as ex: + message = (_("%(msg)s: %(err)s") % + {'msg': fail_msg, 'err': ex._error_string}) + LOG.exception(message) + except Exception as ex: + message = (_("%(msg)s: %(err)s") % + {'msg': fail_msg, 'err': ex.message}) + LOG.exception(message) + return success, message + + return wrapper + + return output_decorator diff --git a/trove/guestagent/module/drivers/new_relic_license_driver.py b/trove/guestagent/module/drivers/new_relic_license_driver.py new file mode 100644 index 0000000000..a9a7670b8d --- /dev/null +++ b/trove/guestagent/module/drivers/new_relic_license_driver.py @@ -0,0 +1,95 @@ +# Copyright 2016 Tesora, Inc. +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. +# + +from datetime import date + +from oslo_log import log as logging + +from trove.common import cfg +from trove.common.i18n import _ +from trove.common import stream_codecs +from trove.common import utils +from trove.guestagent.common import operating_system +from trove.guestagent.module.drivers import module_driver + + +LOG = logging.getLogger(__name__) +CONF = cfg.CONF + + +NR_ADD_LICENSE_CMD = ['nrsysmond-config', '--set', 'license_key=%s'] +NR_SRV_CONTROL_CMD = ['/etc/init.d/newrelic-sysmond'] + + +class NewRelicLicenseDriver(module_driver.ModuleDriver): + """Module to set up the license for the NewRelic service.""" + + def get_description(self): + return "New Relic License Module Driver" + + def get_updated(self): + return date(2016, 4, 12) + + @module_driver.output( + log_message=_('Installing New Relic license key'), + success_message=_('New Relic license key installed'), + fail_message=_('New Relic license key not installed')) + def apply(self, name, datastore, ds_version, data_file, admin_module): + license_key = None + data = operating_system.read_file( + data_file, codec=stream_codecs.KeyValueCodec()) + for key, value in data.items(): + if 'license_key' == key.lower(): + license_key = value + break + if license_key: + self._add_license_key(license_key) + self._server_control('start') + else: + return False, "'license_key' not found in contents file" + + def _add_license_key(self, license_key): + try: + exec_args = {'timeout': 10, + 'run_as_root': True, + 'root_helper': 'sudo'} + cmd = list(NR_ADD_LICENSE_CMD) + cmd[-1] = cmd[-1] % license_key + utils.execute_with_timeout(*cmd, **exec_args) + except Exception: + LOG.exception(_("Could not install license key '%s'") % + license_key) + raise + + def _server_control(self, command): + try: + exec_args = {'timeout': 10, + 'run_as_root': True, + 'root_helper': 'sudo'} + cmd = list(NR_SRV_CONTROL_CMD) + cmd.append(command) + utils.execute_with_timeout(*cmd, **exec_args) + except Exception: + LOG.exception(_("Could not %s New Relic server") % command) + raise + + @module_driver.output( + log_message=_('Removing New Relic license key'), + success_message=_('New Relic license key removed'), + fail_message=_('New Relic license key not removed')) + def remove(self, name, datastore, ds_version, data_file): + self._add_license_key("bad_key_that_is_exactly_40_characters_xx") + self._server_control('stop') diff --git a/trove/guestagent/module/drivers/ping_driver.py b/trove/guestagent/module/drivers/ping_driver.py index 8fc1c8b45c..c842ce4206 100644 --- a/trove/guestagent/module/drivers/ping_driver.py +++ b/trove/guestagent/module/drivers/ping_driver.py @@ -37,37 +37,24 @@ class PingDriver(module_driver.ModuleDriver): be 'Hello.' """ - def get_type(self): - return 'ping' - def get_description(self): - return "Ping Guestagent Module Driver" + return "Ping Module Driver" def get_updated(self): return date(2016, 3, 4) - def apply(self, name, datastore, ds_version, data_file): - success = False - message = "Message not found in contents file" - try: - data = operating_system.read_file( - data_file, codec=stream_codecs.KeyValueCodec()) - for key, value in data.items(): - if 'message' == key.lower(): - success = True - message = value - break - except Exception: - # assume we couldn't read the file, because there was some - # issue with it (for example, it's a binary file). Just log - # it and drive on. - LOG.error(_("Could not extract contents from '%s' - possibly " - "a binary file?") % name) - - return success, message - - def _is_binary(self, data_str): - bool(data_str.translate(None, self.TEXT_CHARS)) + @module_driver.output( + log_message=_('Extracting %(type)s message'), + fail_message=_('Could not extract %(type)s message')) + def apply(self, name, datastore, ds_version, data_file, admin_module): + data = operating_system.read_file( + data_file, codec=stream_codecs.KeyValueCodec()) + for key, value in data.items(): + if 'message' == key.lower(): + return True, value + return False, 'Message not found in contents file' + @module_driver.output( + log_message=_('Removing %(type)s module')) def remove(self, name, datastore, ds_version, data_file): return True, "" diff --git a/trove/guestagent/module/module_manager.py b/trove/guestagent/module/module_manager.py index 6a2c174b79..c41e00da39 100644 --- a/trove/guestagent/module/module_manager.py +++ b/trove/guestagent/module/module_manager.py @@ -61,17 +61,17 @@ class ModuleManager(object): module_type, name, tenant, datastore, ds_version, module_id, md5, auto_apply, visible, now) result = cls.read_module_result(module_dir, default_result) + admin_module = cls.is_admin_module(tenant, auto_apply, visible) try: + driver.configure(name, datastore, ds_version, data_file) applied, message = driver.apply( - name, datastore, ds_version, data_file) + name, datastore, ds_version, data_file, admin_module) except Exception as ex: LOG.exception(_("Could not apply module '%s'") % name) applied = False message = ex.message finally: status = 'OK' if applied else 'ERROR' - admin_only = (not visible or tenant == cls.MODULE_APPLY_TO_ALL or - auto_apply) result['removed'] = None result['status'] = status result['message'] = message @@ -81,7 +81,7 @@ class ModuleManager(object): result['tenant'] = tenant result['auto_apply'] = auto_apply result['visible'] = visible - result['admin_only'] = admin_only + result['admin_only'] = admin_module cls.write_module_result(module_dir, result) return result @@ -112,8 +112,7 @@ class ModuleManager(object): def build_default_result(cls, module_type, name, tenant, datastore, ds_version, module_id, md5, auto_apply, visible, now): - admin_only = (not visible or tenant == cls.MODULE_APPLY_TO_ALL or - auto_apply) + admin_module = cls.is_admin_module(tenant, auto_apply, visible) result = { 'type': module_type, 'name': name, @@ -129,11 +128,16 @@ class ModuleManager(object): 'removed': None, 'auto_apply': auto_apply, 'visible': visible, - 'admin_only': admin_only, + 'admin_only': admin_module, 'contents': None, } return result + @classmethod + def is_admin_module(cls, tenant, auto_apply, visible): + return (not visible or tenant == cls.MODULE_APPLY_TO_ALL or + auto_apply) + @classmethod def read_module_result(cls, result_file, default=None): result_file = cls.get_result_filename(result_file) @@ -203,6 +207,7 @@ class ModuleManager(object): raise exception.NotFound( _("Module '%s' has not been applied") % name) try: + driver.configure(name, datastore, ds_version, contents_file) removed, message = driver.remove( name, datastore, ds_version, contents_file) cls.remove_module_result(module_dir) diff --git a/trove/tests/scenario/runners/module_runners.py b/trove/tests/scenario/runners/module_runners.py index 5b54216984..320cb76f17 100644 --- a/trove/tests/scenario/runners/module_runners.py +++ b/trove/tests/scenario/runners/module_runners.py @@ -16,6 +16,7 @@ import Crypto.Random from proboscis import SkipTest +import re import tempfile from troveclient.compat import exceptions @@ -797,8 +798,10 @@ class ModuleRunner(TestRunner): self.assert_equal(expected_contents, module_apply.contents, '%s Unexpected contents' % prefix) if expected_message is not None: - self.assert_equal(expected_message, module_apply.message, - '%s Unexpected message' % prefix) + regex = re.compile(expected_message) + self.assert_true(regex.match(module_apply.message), + "%s Unexpected message '%s', expected '%s'" % + (prefix, module_apply.message, expected_message)) if expected_status is not None: self.assert_equal(expected_status, module_apply.status, '%s Unexpected status' % prefix) @@ -827,7 +830,8 @@ class ModuleRunner(TestRunner): % module.name) elif self.MODULE_BINARY_SUFFIX in module.name: status = 'ERROR' - message = 'Message not found in contents file' + message = ('^(Could not extract ping message|' + 'Message not found in contents file).*') contents = self.MODULE_BINARY_CONTENTS if self.MODULE_BINARY_SUFFIX2 in module.name: contents = self.MODULE_BINARY_CONTENTS2