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
This commit is contained in:
Peter Stachowski 2016-04-18 11:34:33 -04:00
parent 654a1a228e
commit 31b0fe39b6
9 changed files with 286 additions and 47 deletions

View File

@ -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

View File

@ -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 =

View File

@ -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',

View File

@ -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.")

View File

@ -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 = <dict> 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 = <dict>.
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

View File

@ -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')

View File

@ -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, ""

View File

@ -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)

View File

@ -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