Rebase on percona-cluster-mm

This commit is contained in:
Geoffrey J. Teale 2015-08-04 15:53:02 +02:00
commit 946d369de6
21 changed files with 1380 additions and 154 deletions

View File

@ -2,6 +2,7 @@ branch: lp:charm-helpers
destination: hooks/charmhelpers destination: hooks/charmhelpers
include: include:
- core - core
- cli
- fetch - fetch
- contrib.hahelpers.cluster - contrib.hahelpers.cluster
- contrib.peerstorage - contrib.peerstorage

View File

@ -0,0 +1,195 @@
# Copyright 2014-2015 Canonical Limited.
#
# This file is part of charm-helpers.
#
# charm-helpers is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License version 3 as
# published by the Free Software Foundation.
#
# charm-helpers 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 charm-helpers. If not, see <http://www.gnu.org/licenses/>.
import inspect
import argparse
import sys
from six.moves import zip
from charmhelpers.core import unitdata
class OutputFormatter(object):
def __init__(self, outfile=sys.stdout):
self.formats = (
"raw",
"json",
"py",
"yaml",
"csv",
"tab",
)
self.outfile = outfile
def add_arguments(self, argument_parser):
formatgroup = argument_parser.add_mutually_exclusive_group()
choices = self.supported_formats
formatgroup.add_argument("--format", metavar='FMT',
help="Select output format for returned data, "
"where FMT is one of: {}".format(choices),
choices=choices, default='raw')
for fmt in self.formats:
fmtfunc = getattr(self, fmt)
formatgroup.add_argument("-{}".format(fmt[0]),
"--{}".format(fmt), action='store_const',
const=fmt, dest='format',
help=fmtfunc.__doc__)
@property
def supported_formats(self):
return self.formats
def raw(self, output):
"""Output data as raw string (default)"""
if isinstance(output, (list, tuple)):
output = '\n'.join(map(str, output))
self.outfile.write(str(output))
def py(self, output):
"""Output data as a nicely-formatted python data structure"""
import pprint
pprint.pprint(output, stream=self.outfile)
def json(self, output):
"""Output data in JSON format"""
import json
json.dump(output, self.outfile)
def yaml(self, output):
"""Output data in YAML format"""
import yaml
yaml.safe_dump(output, self.outfile)
def csv(self, output):
"""Output data as excel-compatible CSV"""
import csv
csvwriter = csv.writer(self.outfile)
csvwriter.writerows(output)
def tab(self, output):
"""Output data in excel-compatible tab-delimited format"""
import csv
csvwriter = csv.writer(self.outfile, dialect=csv.excel_tab)
csvwriter.writerows(output)
def format_output(self, output, fmt='raw'):
fmtfunc = getattr(self, fmt)
fmtfunc(output)
class CommandLine(object):
argument_parser = None
subparsers = None
formatter = None
exit_code = 0
def __init__(self):
if not self.argument_parser:
self.argument_parser = argparse.ArgumentParser(description='Perform common charm tasks')
if not self.formatter:
self.formatter = OutputFormatter()
self.formatter.add_arguments(self.argument_parser)
if not self.subparsers:
self.subparsers = self.argument_parser.add_subparsers(help='Commands')
def subcommand(self, command_name=None):
"""
Decorate a function as a subcommand. Use its arguments as the
command-line arguments"""
def wrapper(decorated):
cmd_name = command_name or decorated.__name__
subparser = self.subparsers.add_parser(cmd_name,
description=decorated.__doc__)
for args, kwargs in describe_arguments(decorated):
subparser.add_argument(*args, **kwargs)
subparser.set_defaults(func=decorated)
return decorated
return wrapper
def test_command(self, decorated):
"""
Subcommand is a boolean test function, so bool return values should be
converted to a 0/1 exit code.
"""
decorated._cli_test_command = True
return decorated
def no_output(self, decorated):
"""
Subcommand is not expected to return a value, so don't print a spurious None.
"""
decorated._cli_no_output = True
return decorated
def subcommand_builder(self, command_name, description=None):
"""
Decorate a function that builds a subcommand. Builders should accept a
single argument (the subparser instance) and return the function to be
run as the command."""
def wrapper(decorated):
subparser = self.subparsers.add_parser(command_name)
func = decorated(subparser)
subparser.set_defaults(func=func)
subparser.description = description or func.__doc__
return wrapper
def run(self):
"Run cli, processing arguments and executing subcommands."
arguments = self.argument_parser.parse_args()
argspec = inspect.getargspec(arguments.func)
vargs = []
kwargs = {}
for arg in argspec.args:
vargs.append(getattr(arguments, arg))
if argspec.varargs:
vargs.extend(getattr(arguments, argspec.varargs))
if argspec.keywords:
for kwarg in argspec.keywords.items():
kwargs[kwarg] = getattr(arguments, kwarg)
output = arguments.func(*vargs, **kwargs)
if getattr(arguments.func, '_cli_test_command', False):
self.exit_code = 0 if output else 1
output = ''
if getattr(arguments.func, '_cli_no_output', False):
output = ''
self.formatter.format_output(output, arguments.format)
if unitdata._KV:
unitdata._KV.flush()
cmdline = CommandLine()
def describe_arguments(func):
"""
Analyze a function's signature and return a data structure suitable for
passing in as arguments to an argparse parser's add_argument() method."""
argspec = inspect.getargspec(func)
# we should probably raise an exception somewhere if func includes **kwargs
if argspec.defaults:
positional_args = argspec.args[:-len(argspec.defaults)]
keyword_names = argspec.args[-len(argspec.defaults):]
for arg, default in zip(keyword_names, argspec.defaults):
yield ('--{}'.format(arg),), {'default': default}
else:
positional_args = argspec.args
for arg in positional_args:
yield (arg,), {}
if argspec.varargs:
yield (argspec.varargs,), {'nargs': '*'}

View File

@ -0,0 +1,36 @@
# Copyright 2014-2015 Canonical Limited.
#
# This file is part of charm-helpers.
#
# charm-helpers is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License version 3 as
# published by the Free Software Foundation.
#
# charm-helpers 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 charm-helpers. If not, see <http://www.gnu.org/licenses/>.
from . import cmdline
from charmhelpers.contrib.benchmark import Benchmark
@cmdline.subcommand(command_name='benchmark-start')
def start():
Benchmark.start()
@cmdline.subcommand(command_name='benchmark-finish')
def finish():
Benchmark.finish()
@cmdline.subcommand_builder('benchmark-composite', description="Set the benchmark composite score")
def service(subparser):
subparser.add_argument("value", help="The composite score.")
subparser.add_argument("units", help="The units the composite score represents, i.e., 'reads/sec'.")
subparser.add_argument("direction", help="'asc' if a lower score is better, 'desc' if a higher score is better.")
return Benchmark.set_composite_score

View File

@ -0,0 +1,32 @@
# Copyright 2014-2015 Canonical Limited.
#
# This file is part of charm-helpers.
#
# charm-helpers is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License version 3 as
# published by the Free Software Foundation.
#
# charm-helpers 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 charm-helpers. If not, see <http://www.gnu.org/licenses/>.
"""
This module loads sub-modules into the python runtime so they can be
discovered via the inspect module. In order to prevent flake8 from (rightfully)
telling us these are unused modules, throw a ' # noqa' at the end of each import
so that the warning is suppressed.
"""
from . import CommandLine # noqa
"""
Import the sub-modules which have decorated subcommands to register with chlp.
"""
import host # noqa
import benchmark # noqa
import unitdata # noqa
from charmhelpers.core import hookenv # noqa

31
charmhelpers/cli/host.py Normal file
View File

@ -0,0 +1,31 @@
# Copyright 2014-2015 Canonical Limited.
#
# This file is part of charm-helpers.
#
# charm-helpers is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License version 3 as
# published by the Free Software Foundation.
#
# charm-helpers 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 charm-helpers. If not, see <http://www.gnu.org/licenses/>.
from . import cmdline
from charmhelpers.core import host
@cmdline.subcommand()
def mounts():
"List mounts"
return host.mounts()
@cmdline.subcommand_builder('service', description="Control system services")
def service(subparser):
subparser.add_argument("action", help="The action to perform (start, stop, etc...)")
subparser.add_argument("service_name", help="Name of the service to control")
return host.service

View File

@ -0,0 +1,39 @@
# Copyright 2014-2015 Canonical Limited.
#
# This file is part of charm-helpers.
#
# charm-helpers is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License version 3 as
# published by the Free Software Foundation.
#
# charm-helpers 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 charm-helpers. If not, see <http://www.gnu.org/licenses/>.
from . import cmdline
from charmhelpers.core import unitdata
@cmdline.subcommand_builder('unitdata', description="Store and retrieve data")
def unitdata_cmd(subparser):
nested = subparser.add_subparsers()
get_cmd = nested.add_parser('get', help='Retrieve data')
get_cmd.add_argument('key', help='Key to retrieve the value of')
get_cmd.set_defaults(action='get', value=None)
set_cmd = nested.add_parser('set', help='Store data')
set_cmd.add_argument('key', help='Key to set')
set_cmd.add_argument('value', help='Value to store')
set_cmd.set_defaults(action='set')
def _unitdata_cmd(action, key, value):
if action == 'get':
return unitdata.kv().get(key)
elif action == 'set':
unitdata.kv().set(key, value)
unitdata.kv().flush()
return ''
return _unitdata_cmd

View File

@ -381,6 +381,9 @@ class PerconaClusterHelper(object):
if 'wait-timeout' in config: if 'wait-timeout' in config:
mysql_config['wait_timeout'] = config['wait-timeout'] mysql_config['wait_timeout'] = config['wait-timeout']
if 'innodb-flush-log-at-trx-commit' in config:
mysql_config['innodb_flush_log_at_trx_commit'] = config['innodb-flush-log-at-trx-commit']
# Set a sane default key_buffer size # Set a sane default key_buffer size
mysql_config['key_buffer'] = self.human_to_bytes('32M') mysql_config['key_buffer'] = self.human_to_bytes('32M')
total_memory = self.human_to_bytes(self.get_mem_total()) total_memory = self.human_to_bytes(self.get_mem_total())

View File

@ -64,6 +64,10 @@ class CRMResourceNotFound(Exception):
pass pass
class CRMDCNotFound(Exception):
pass
def is_elected_leader(resource): def is_elected_leader(resource):
""" """
Returns True if the charm executing this is the elected cluster leader. Returns True if the charm executing this is the elected cluster leader.
@ -116,8 +120,9 @@ def is_crm_dc():
status = subprocess.check_output(cmd, stderr=subprocess.STDOUT) status = subprocess.check_output(cmd, stderr=subprocess.STDOUT)
if not isinstance(status, six.text_type): if not isinstance(status, six.text_type):
status = six.text_type(status, "utf-8") status = six.text_type(status, "utf-8")
except subprocess.CalledProcessError: except subprocess.CalledProcessError as ex:
return False raise CRMDCNotFound(str(ex))
current_dc = '' current_dc = ''
for line in status.split('\n'): for line in status.split('\n'):
if line.startswith('Current DC'): if line.startswith('Current DC'):
@ -125,10 +130,14 @@ def is_crm_dc():
current_dc = line.split(':')[1].split()[0] current_dc = line.split(':')[1].split()[0]
if current_dc == get_unit_hostname(): if current_dc == get_unit_hostname():
return True return True
elif current_dc == 'NONE':
raise CRMDCNotFound('Current DC: NONE')
return False return False
@retry_on_exception(5, base_delay=2, exc_type=CRMResourceNotFound) @retry_on_exception(5, base_delay=2,
exc_type=(CRMResourceNotFound, CRMDCNotFound))
def is_crm_leader(resource, retry=False): def is_crm_leader(resource, retry=False):
""" """
Returns True if the charm calling this is the elected corosync leader, Returns True if the charm calling this is the elected corosync leader,

View File

@ -59,7 +59,7 @@ def some_hook():
""" """
def leader_get(attribute=None): def leader_get(attribute=None, rid=None):
"""Wrapper to ensure that settings are migrated from the peer relation. """Wrapper to ensure that settings are migrated from the peer relation.
This is to support upgrading an environment that does not support This is to support upgrading an environment that does not support
@ -94,7 +94,8 @@ def leader_get(attribute=None):
# If attribute not present in leader db, check if this unit has set # If attribute not present in leader db, check if this unit has set
# the attribute in the peer relation # the attribute in the peer relation
if not leader_settings: if not leader_settings:
peer_setting = relation_get(attribute=attribute, unit=local_unit()) peer_setting = _relation_get(attribute=attribute, unit=local_unit(),
rid=rid)
if peer_setting: if peer_setting:
leader_set(settings={attribute: peer_setting}) leader_set(settings={attribute: peer_setting})
leader_settings = peer_setting leader_settings = peer_setting
@ -103,7 +104,7 @@ def leader_get(attribute=None):
settings_migrated = True settings_migrated = True
migrated.add(attribute) migrated.add(attribute)
else: else:
r_settings = relation_get(unit=local_unit()) r_settings = _relation_get(unit=local_unit(), rid=rid)
if r_settings: if r_settings:
for key in set(r_settings.keys()).difference(migrated): for key in set(r_settings.keys()).difference(migrated):
# Leader setting wins # Leader setting wins
@ -151,7 +152,7 @@ def relation_get(attribute=None, unit=None, rid=None):
""" """
try: try:
if rid in relation_ids('cluster'): if rid in relation_ids('cluster'):
return leader_get(attribute) return leader_get(attribute, rid)
else: else:
raise NotImplementedError raise NotImplementedError
except NotImplementedError: except NotImplementedError:

View File

@ -0,0 +1,45 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# Copyright 2014-2015 Canonical Limited.
#
# This file is part of charm-helpers.
#
# charm-helpers is free software: you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License version 3 as
# published by the Free Software Foundation.
#
# charm-helpers 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 charm-helpers. If not, see <http://www.gnu.org/licenses/>.
__author__ = 'Jorge Niedbalski <niedbalski@ubuntu.com>'
import os
import subprocess
def sed(filename, before, after, flags='g'):
"""
Search and replaces the given pattern on filename.
:param filename: relative or absolute file path.
:param before: expression to be replaced (see 'man sed')
:param after: expression to replace with (see 'man sed')
:param flags: sed-compatible regex flags in example, to make
the search and replace case insensitive, specify ``flags="i"``.
The ``g`` flag is always specified regardless, so you do not
need to remember to include it when overriding this parameter.
:returns: If the sed command exit code was zero then return,
otherwise raise CalledProcessError.
"""
expression = r's/{0}/{1}/{2}'.format(before,
after, flags)
return subprocess.check_call(["sed", "-i", "-r", "-e",
expression,
os.path.expanduser(filename)])

View File

@ -21,7 +21,10 @@
# Charm Helpers Developers <juju@lists.ubuntu.com> # Charm Helpers Developers <juju@lists.ubuntu.com>
from __future__ import print_function from __future__ import print_function
import copy
from distutils.version import LooseVersion
from functools import wraps from functools import wraps
import glob
import os import os
import json import json
import yaml import yaml
@ -31,6 +34,23 @@ import errno
import tempfile import tempfile
from subprocess import CalledProcessError from subprocess import CalledProcessError
try:
from charmhelpers.cli import cmdline
except ImportError as e:
# due to the anti-pattern of partially synching charmhelpers directly
# into charms, it's possible that charmhelpers.cli is not available;
# if that's the case, they don't really care about using the cli anyway,
# so mock it out
if str(e) == 'No module named cli':
class cmdline(object):
@classmethod
def subcommand(cls, *args, **kwargs):
def _wrap(func):
return func
return _wrap
else:
raise
import six import six
if not six.PY3: if not six.PY3:
from UserDict import UserDict from UserDict import UserDict
@ -170,9 +190,20 @@ def relation_type():
return os.environ.get('JUJU_RELATION', None) return os.environ.get('JUJU_RELATION', None)
def relation_id(): @cmdline.subcommand()
"""The relation ID for the current relation hook""" @cached
return os.environ.get('JUJU_RELATION_ID', None) def relation_id(relation_name=None, service_or_unit=None):
"""The relation ID for the current or a specified relation"""
if not relation_name and not service_or_unit:
return os.environ.get('JUJU_RELATION_ID', None)
elif relation_name and service_or_unit:
service_name = service_or_unit.split('/')[0]
for relid in relation_ids(relation_name):
remote_service = remote_service_name(relid)
if remote_service == service_name:
return relid
else:
raise ValueError('Must specify neither or both of relation_name and service_or_unit')
def local_unit(): def local_unit():
@ -185,14 +216,27 @@ def remote_unit():
return os.environ.get('JUJU_REMOTE_UNIT', None) return os.environ.get('JUJU_REMOTE_UNIT', None)
@cmdline.subcommand()
def service_name(): def service_name():
"""The name service group this unit belongs to""" """The name service group this unit belongs to"""
return local_unit().split('/')[0] return local_unit().split('/')[0]
@cmdline.subcommand()
@cached
def remote_service_name(relid=None):
"""The remote service name for a given relation-id (or the current relation)"""
if relid is None:
unit = remote_unit()
else:
units = related_units(relid)
unit = units[0] if units else None
return unit.split('/')[0] if unit else None
def hook_name(): def hook_name():
"""The name of the currently executing hook""" """The name of the currently executing hook"""
return os.path.basename(sys.argv[0]) return os.environ.get('JUJU_HOOK_NAME', os.path.basename(sys.argv[0]))
class Config(dict): class Config(dict):
@ -242,29 +286,7 @@ class Config(dict):
self.path = os.path.join(charm_dir(), Config.CONFIG_FILE_NAME) self.path = os.path.join(charm_dir(), Config.CONFIG_FILE_NAME)
if os.path.exists(self.path): if os.path.exists(self.path):
self.load_previous() self.load_previous()
atexit(self._implicit_save)
def __getitem__(self, key):
"""For regular dict lookups, check the current juju config first,
then the previous (saved) copy. This ensures that user-saved values
will be returned by a dict lookup.
"""
try:
return dict.__getitem__(self, key)
except KeyError:
return (self._prev_dict or {})[key]
def get(self, key, default=None):
try:
return self[key]
except KeyError:
return default
def keys(self):
prev_keys = []
if self._prev_dict is not None:
prev_keys = self._prev_dict.keys()
return list(set(prev_keys + list(dict.keys(self))))
def load_previous(self, path=None): def load_previous(self, path=None):
"""Load previous copy of config from disk. """Load previous copy of config from disk.
@ -283,6 +305,9 @@ class Config(dict):
self.path = path or self.path self.path = path or self.path
with open(self.path) as f: with open(self.path) as f:
self._prev_dict = json.load(f) self._prev_dict = json.load(f)
for k, v in copy.deepcopy(self._prev_dict).items():
if k not in self:
self[k] = v
def changed(self, key): def changed(self, key):
"""Return True if the current value for this key is different from """Return True if the current value for this key is different from
@ -314,13 +339,13 @@ class Config(dict):
instance. instance.
""" """
if self._prev_dict:
for k, v in six.iteritems(self._prev_dict):
if k not in self:
self[k] = v
with open(self.path, 'w') as f: with open(self.path, 'w') as f:
json.dump(self, f) json.dump(self, f)
def _implicit_save(self):
if self.implicit_save:
self.save()
@cached @cached
def config(scope=None): def config(scope=None):
@ -484,6 +509,63 @@ def relation_types():
return rel_types return rel_types
@cached
def relation_to_interface(relation_name):
"""
Given the name of a relation, return the interface that relation uses.
:returns: The interface name, or ``None``.
"""
return relation_to_role_and_interface(relation_name)[1]
@cached
def relation_to_role_and_interface(relation_name):
"""
Given the name of a relation, return the role and the name of the interface
that relation uses (where role is one of ``provides``, ``requires``, or ``peer``).
:returns: A tuple containing ``(role, interface)``, or ``(None, None)``.
"""
_metadata = metadata()
for role in ('provides', 'requires', 'peer'):
interface = _metadata.get(role, {}).get(relation_name, {}).get('interface')
if interface:
return role, interface
return None, None
@cached
def role_and_interface_to_relations(role, interface_name):
"""
Given a role and interface name, return a list of relation names for the
current charm that use that interface under that role (where role is one
of ``provides``, ``requires``, or ``peer``).
:returns: A list of relation names.
"""
_metadata = metadata()
results = []
for relation_name, relation in _metadata.get(role, {}).items():
if relation['interface'] == interface_name:
results.append(relation_name)
return results
@cached
def interface_to_relations(interface_name):
"""
Given an interface, return a list of relation names for the current
charm that use that interface.
:returns: A list of relation names.
"""
results = []
for role in ('provides', 'requires', 'peer'):
results.extend(role_and_interface_to_relations(role, interface_name))
return results
@cached @cached
def charm_name(): def charm_name():
"""Get the name of the current charm as is specified on metadata.yaml""" """Get the name of the current charm as is specified on metadata.yaml"""
@ -587,10 +669,14 @@ class Hooks(object):
hooks.execute(sys.argv) hooks.execute(sys.argv)
""" """
def __init__(self, config_save=True): def __init__(self, config_save=None):
super(Hooks, self).__init__() super(Hooks, self).__init__()
self._hooks = {} self._hooks = {}
self._config_save = config_save
# For unknown reasons, we allow the Hooks constructor to override
# config().implicit_save.
if config_save is not None:
config().implicit_save = config_save
def register(self, name, function): def register(self, name, function):
"""Register a hook""" """Register a hook"""
@ -598,13 +684,16 @@ class Hooks(object):
def execute(self, args): def execute(self, args):
"""Execute a registered hook based on args[0]""" """Execute a registered hook based on args[0]"""
_run_atstart()
hook_name = os.path.basename(args[0]) hook_name = os.path.basename(args[0])
if hook_name in self._hooks: if hook_name in self._hooks:
self._hooks[hook_name]() try:
if self._config_save: self._hooks[hook_name]()
cfg = config() except SystemExit as x:
if cfg.implicit_save: if x.code is None or x.code == 0:
cfg.save() _run_atexit()
raise
_run_atexit()
else: else:
raise UnregisteredHookError(hook_name) raise UnregisteredHookError(hook_name)
@ -653,6 +742,21 @@ def action_fail(message):
subprocess.check_call(['action-fail', message]) subprocess.check_call(['action-fail', message])
def action_name():
"""Get the name of the currently executing action."""
return os.environ.get('JUJU_ACTION_NAME')
def action_uuid():
"""Get the UUID of the currently executing action."""
return os.environ.get('JUJU_ACTION_UUID')
def action_tag():
"""Get the tag for the currently executing action."""
return os.environ.get('JUJU_ACTION_TAG')
def status_set(workload_state, message): def status_set(workload_state, message):
"""Set the workload state with a message """Set the workload state with a message
@ -732,13 +836,80 @@ def leader_get(attribute=None):
@translate_exc(from_exc=OSError, to_exc=NotImplementedError) @translate_exc(from_exc=OSError, to_exc=NotImplementedError)
def leader_set(settings=None, **kwargs): def leader_set(settings=None, **kwargs):
"""Juju leader set value(s)""" """Juju leader set value(s)"""
log("Juju leader-set '%s'" % (settings), level=DEBUG) # Don't log secrets.
# log("Juju leader-set '%s'" % (settings), level=DEBUG)
cmd = ['leader-set'] cmd = ['leader-set']
settings = settings or {} settings = settings or {}
settings.update(kwargs) settings.update(kwargs)
for k, v in settings.iteritems(): for k, v in settings.items():
if v is None: if v is None:
cmd.append('{}='.format(k)) cmd.append('{}='.format(k))
else: else:
cmd.append('{}={}'.format(k, v)) cmd.append('{}={}'.format(k, v))
subprocess.check_call(cmd) subprocess.check_call(cmd)
@cached
def juju_version():
"""Full version string (eg. '1.23.3.1-trusty-amd64')"""
# Per https://bugs.launchpad.net/juju-core/+bug/1455368/comments/1
jujud = glob.glob('/var/lib/juju/tools/machine-*/jujud')[0]
return subprocess.check_output([jujud, 'version'],
universal_newlines=True).strip()
@cached
def has_juju_version(minimum_version):
"""Return True if the Juju version is at least the provided version"""
return LooseVersion(juju_version()) >= LooseVersion(minimum_version)
_atexit = []
_atstart = []
def atstart(callback, *args, **kwargs):
'''Schedule a callback to run before the main hook.
Callbacks are run in the order they were added.
This is useful for modules and classes to perform initialization
and inject behavior. In particular:
- Run common code before all of your hooks, such as logging
the hook name or interesting relation data.
- Defer object or module initialization that requires a hook
context until we know there actually is a hook context,
making testing easier.
- Rather than requiring charm authors to include boilerplate to
invoke your helper's behavior, have it run automatically if
your object is instantiated or module imported.
This is not at all useful after your hook framework as been launched.
'''
global _atstart
_atstart.append((callback, args, kwargs))
def atexit(callback, *args, **kwargs):
'''Schedule a callback to run on successful hook completion.
Callbacks are run in the reverse order that they were added.'''
_atexit.append((callback, args, kwargs))
def _run_atstart():
'''Hook frameworks must invoke this before running the main hook body.'''
global _atstart
for callback, args, kwargs in _atstart:
callback(*args, **kwargs)
del _atstart[:]
def _run_atexit():
'''Hook frameworks must invoke this after the main hook body has
successfully completed. Do not invoke it if the hook fails.'''
global _atexit
for callback, args, kwargs in reversed(_atexit):
callback(*args, **kwargs)
del _atexit[:]

View File

@ -24,6 +24,7 @@
import os import os
import re import re
import pwd import pwd
import glob
import grp import grp
import random import random
import string import string
@ -62,6 +63,36 @@ def service_reload(service_name, restart_on_failure=False):
return service_result return service_result
def service_pause(service_name, init_dir=None):
"""Pause a system service.
Stop it, and prevent it from starting again at boot."""
if init_dir is None:
init_dir = "/etc/init"
stopped = service_stop(service_name)
# XXX: Support systemd too
override_path = os.path.join(
init_dir, '{}.conf.override'.format(service_name))
with open(override_path, 'w') as fh:
fh.write("manual\n")
return stopped
def service_resume(service_name, init_dir=None):
"""Resume a system service.
Reenable starting again at boot. Start the service"""
# XXX: Support systemd too
if init_dir is None:
init_dir = "/etc/init"
override_path = os.path.join(
init_dir, '{}.conf.override'.format(service_name))
if os.path.exists(override_path):
os.unlink(override_path)
started = service_start(service_name)
return started
def service(action, service_name): def service(action, service_name):
"""Control a system service""" """Control a system service"""
cmd = ['service', service_name, action] cmd = ['service', service_name, action]
@ -139,11 +170,7 @@ def add_group(group_name, system_group=False):
def add_user_to_group(username, group): def add_user_to_group(username, group):
"""Add a user to a group""" """Add a user to a group"""
cmd = [ cmd = ['gpasswd', '-a', username, group]
'gpasswd', '-a',
username,
group
]
log("Adding user {} to group {}".format(username, group)) log("Adding user {} to group {}".format(username, group))
subprocess.check_call(cmd) subprocess.check_call(cmd)
@ -269,6 +296,21 @@ def file_hash(path, hash_type='md5'):
return None return None
def path_hash(path):
"""
Generate a hash checksum of all files matching 'path'. Standard wildcards
like '*' and '?' are supported, see documentation for the 'glob' module for
more information.
:return: dict: A { filename: hash } dictionary for all matched files.
Empty if none found.
"""
return {
filename: file_hash(filename)
for filename in glob.iglob(path)
}
def check_hash(path, checksum, hash_type='md5'): def check_hash(path, checksum, hash_type='md5'):
""" """
Validate a file using a cryptographic checksum. Validate a file using a cryptographic checksum.
@ -296,23 +338,25 @@ def restart_on_change(restart_map, stopstart=False):
@restart_on_change({ @restart_on_change({
'/etc/ceph/ceph.conf': [ 'cinder-api', 'cinder-volume' ] '/etc/ceph/ceph.conf': [ 'cinder-api', 'cinder-volume' ]
'/etc/apache/sites-enabled/*': [ 'apache2' ]
}) })
def ceph_client_changed(): def config_changed():
pass # your code here pass # your code here
In this example, the cinder-api and cinder-volume services In this example, the cinder-api and cinder-volume services
would be restarted if /etc/ceph/ceph.conf is changed by the would be restarted if /etc/ceph/ceph.conf is changed by the
ceph_client_changed function. ceph_client_changed function. The apache2 service would be
restarted if any file matching the pattern got changed, created
or removed. Standard wildcards are supported, see documentation
for the 'glob' module for more information.
""" """
def wrap(f): def wrap(f):
def wrapped_f(*args, **kwargs): def wrapped_f(*args, **kwargs):
checksums = {} checksums = {path: path_hash(path) for path in restart_map}
for path in restart_map:
checksums[path] = file_hash(path)
f(*args, **kwargs) f(*args, **kwargs)
restarts = [] restarts = []
for path in restart_map: for path in restart_map:
if checksums[path] != file_hash(path): if path_hash(path) != checksums[path]:
restarts += restart_map[path] restarts += restart_map[path]
services_list = list(OrderedDict.fromkeys(restarts)) services_list = list(OrderedDict.fromkeys(restarts))
if not stopstart: if not stopstart:

View File

@ -128,15 +128,18 @@ class ServiceManager(object):
""" """
Handle the current hook by doing The Right Thing with the registered services. Handle the current hook by doing The Right Thing with the registered services.
""" """
hook_name = hookenv.hook_name() hookenv._run_atstart()
if hook_name == 'stop': try:
self.stop_services() hook_name = hookenv.hook_name()
else: if hook_name == 'stop':
self.reconfigure_services() self.stop_services()
self.provide_data() else:
cfg = hookenv.config() self.reconfigure_services()
if cfg.implicit_save: self.provide_data()
cfg.save() except SystemExit as x:
if x.code is None or x.code == 0:
hookenv._run_atexit()
hookenv._run_atexit()
def provide_data(self): def provide_data(self):
""" """

View File

@ -239,12 +239,12 @@ class TemplateCallback(ManagerCallback):
action. action.
:param str source: The template source file, relative to :param str source: The template source file, relative to
`$CHARM_DIR/templates` `$CHARM_DIR/templates`
:param str target: The target to write the rendered template to :param str target: The target to write the rendered template to
:param str owner: The owner of the rendered file :param str owner: The owner of the rendered file
:param str group: The group of the rendered file :param str group: The group of the rendered file
:param int perms: The permissions of the rendered file :param int perms: The permissions of the rendered file
""" """
def __init__(self, source, target, def __init__(self, source, target,
owner='root', group='root', perms=0o444): owner='root', group='root', perms=0o444):

View File

@ -152,6 +152,7 @@ associated to the hookname.
import collections import collections
import contextlib import contextlib
import datetime import datetime
import itertools
import json import json
import os import os
import pprint import pprint
@ -164,8 +165,7 @@ __author__ = 'Kapil Thangavelu <kapil.foss@gmail.com>'
class Storage(object): class Storage(object):
"""Simple key value database for local unit state within charms. """Simple key value database for local unit state within charms.
Modifications are automatically committed at hook exit. That's Modifications are not persisted unless :meth:`flush` is called.
currently regardless of exit code.
To support dicts, lists, integer, floats, and booleans values To support dicts, lists, integer, floats, and booleans values
are automatically json encoded/decoded. are automatically json encoded/decoded.
@ -173,8 +173,11 @@ class Storage(object):
def __init__(self, path=None): def __init__(self, path=None):
self.db_path = path self.db_path = path
if path is None: if path is None:
self.db_path = os.path.join( if 'UNIT_STATE_DB' in os.environ:
os.environ.get('CHARM_DIR', ''), '.unit-state.db') self.db_path = os.environ['UNIT_STATE_DB']
else:
self.db_path = os.path.join(
os.environ.get('CHARM_DIR', ''), '.unit-state.db')
self.conn = sqlite3.connect('%s' % self.db_path) self.conn = sqlite3.connect('%s' % self.db_path)
self.cursor = self.conn.cursor() self.cursor = self.conn.cursor()
self.revision = None self.revision = None
@ -189,15 +192,8 @@ class Storage(object):
self.conn.close() self.conn.close()
self._closed = True self._closed = True
def _scoped_query(self, stmt, params=None):
if params is None:
params = []
return stmt, params
def get(self, key, default=None, record=False): def get(self, key, default=None, record=False):
self.cursor.execute( self.cursor.execute('select data from kv where key=?', [key])
*self._scoped_query(
'select data from kv where key=?', [key]))
result = self.cursor.fetchone() result = self.cursor.fetchone()
if not result: if not result:
return default return default
@ -206,33 +202,81 @@ class Storage(object):
return json.loads(result[0]) return json.loads(result[0])
def getrange(self, key_prefix, strip=False): def getrange(self, key_prefix, strip=False):
stmt = "select key, data from kv where key like '%s%%'" % key_prefix """
self.cursor.execute(*self._scoped_query(stmt)) Get a range of keys starting with a common prefix as a mapping of
keys to values.
:param str key_prefix: Common prefix among all keys
:param bool strip: Optionally strip the common prefix from the key
names in the returned dict
:return dict: A (possibly empty) dict of key-value mappings
"""
self.cursor.execute("select key, data from kv where key like ?",
['%s%%' % key_prefix])
result = self.cursor.fetchall() result = self.cursor.fetchall()
if not result: if not result:
return None return {}
if not strip: if not strip:
key_prefix = '' key_prefix = ''
return dict([ return dict([
(k[len(key_prefix):], json.loads(v)) for k, v in result]) (k[len(key_prefix):], json.loads(v)) for k, v in result])
def update(self, mapping, prefix=""): def update(self, mapping, prefix=""):
"""
Set the values of multiple keys at once.
:param dict mapping: Mapping of keys to values
:param str prefix: Optional prefix to apply to all keys in `mapping`
before setting
"""
for k, v in mapping.items(): for k, v in mapping.items():
self.set("%s%s" % (prefix, k), v) self.set("%s%s" % (prefix, k), v)
def unset(self, key): def unset(self, key):
"""
Remove a key from the database entirely.
"""
self.cursor.execute('delete from kv where key=?', [key]) self.cursor.execute('delete from kv where key=?', [key])
if self.revision and self.cursor.rowcount: if self.revision and self.cursor.rowcount:
self.cursor.execute( self.cursor.execute(
'insert into kv_revisions values (?, ?, ?)', 'insert into kv_revisions values (?, ?, ?)',
[key, self.revision, json.dumps('DELETED')]) [key, self.revision, json.dumps('DELETED')])
def unsetrange(self, keys=None, prefix=""):
"""
Remove a range of keys starting with a common prefix, from the database
entirely.
:param list keys: List of keys to remove.
:param str prefix: Optional prefix to apply to all keys in ``keys``
before removing.
"""
if keys is not None:
keys = ['%s%s' % (prefix, key) for key in keys]
self.cursor.execute('delete from kv where key in (%s)' % ','.join(['?'] * len(keys)), keys)
if self.revision and self.cursor.rowcount:
self.cursor.execute(
'insert into kv_revisions values %s' % ','.join(['(?, ?, ?)'] * len(keys)),
list(itertools.chain.from_iterable((key, self.revision, json.dumps('DELETED')) for key in keys)))
else:
self.cursor.execute('delete from kv where key like ?',
['%s%%' % prefix])
if self.revision and self.cursor.rowcount:
self.cursor.execute(
'insert into kv_revisions values (?, ?, ?)',
['%s%%' % prefix, self.revision, json.dumps('DELETED')])
def set(self, key, value): def set(self, key, value):
"""
Set a value in the database.
:param str key: Key to set the value for
:param value: Any JSON-serializable value to be set
"""
serialized = json.dumps(value) serialized = json.dumps(value)
self.cursor.execute( self.cursor.execute('select data from kv where key=?', [key])
'select data from kv where key=?', [key])
exists = self.cursor.fetchone() exists = self.cursor.fetchone()
# Skip mutations to the same value # Skip mutations to the same value

View File

@ -215,9 +215,9 @@ def apt_purge(packages, fatal=False):
_run_apt_command(cmd, fatal) _run_apt_command(cmd, fatal)
def apt_hold(packages, fatal=False): def apt_mark(packages, mark, fatal=False):
"""Hold one or more packages""" """Flag one or more packages using apt-mark"""
cmd = ['apt-mark', 'hold'] cmd = ['apt-mark', mark]
if isinstance(packages, six.string_types): if isinstance(packages, six.string_types):
cmd.append(packages) cmd.append(packages)
else: else:
@ -225,9 +225,17 @@ def apt_hold(packages, fatal=False):
log("Holding {}".format(packages)) log("Holding {}".format(packages))
if fatal: if fatal:
subprocess.check_call(cmd) subprocess.check_call(cmd, universal_newlines=True)
else: else:
subprocess.call(cmd) subprocess.call(cmd, universal_newlines=True)
def apt_hold(packages, fatal=False):
return apt_mark(packages, 'hold', fatal=fatal)
def apt_unhold(packages, fatal=False):
return apt_mark(packages, 'unhold', fatal=fatal)
def add_source(source, key=None): def add_source(source, key=None):
@ -370,8 +378,9 @@ def install_remote(source, *args, **kwargs):
for handler in handlers: for handler in handlers:
try: try:
installed_to = handler.install(source, *args, **kwargs) installed_to = handler.install(source, *args, **kwargs)
except UnhandledSource: except UnhandledSource as e:
pass log('Install source attempt unsuccessful: {}'.format(e),
level='WARNING')
if not installed_to: if not installed_to:
raise UnhandledSource("No handler found for source {}".format(source)) raise UnhandledSource("No handler found for source {}".format(source))
return installed_to return installed_to

View File

@ -77,6 +77,8 @@ class ArchiveUrlFetchHandler(BaseFetchHandler):
def can_handle(self, source): def can_handle(self, source):
url_parts = self.parse_url(source) url_parts = self.parse_url(source)
if url_parts.scheme not in ('http', 'https', 'ftp', 'file'): if url_parts.scheme not in ('http', 'https', 'ftp', 'file'):
# XXX: Why is this returning a boolean and a string? It's
# doomed to fail since "bool(can_handle('foo://'))" will be True.
return "Wrong source type" return "Wrong source type"
if get_archive_handler(self.base_url(source)): if get_archive_handler(self.base_url(source)):
return True return True
@ -155,7 +157,11 @@ class ArchiveUrlFetchHandler(BaseFetchHandler):
else: else:
algorithms = hashlib.algorithms_available algorithms = hashlib.algorithms_available
if key in algorithms: if key in algorithms:
check_hash(dld_file, value, key) if len(value) != 1:
raise TypeError(
"Expected 1 hash value, not %d" % len(value))
expected = value[0]
check_hash(dld_file, expected, key)
if checksum: if checksum:
check_hash(dld_file, checksum, hash_type) check_hash(dld_file, checksum, hash_type)
return extract(dld_file, dest) return extract(dld_file, dest)

View File

@ -67,7 +67,7 @@ class GitUrlFetchHandler(BaseFetchHandler):
try: try:
self.clone(source, dest_dir, branch, depth) self.clone(source, dest_dir, branch, depth)
except GitCommandError as e: except GitCommandError as e:
raise UnhandledSource(e.message) raise UnhandledSource(e)
except OSError as e: except OSError as e:
raise UnhandledSource(e.strerror) raise UnhandledSource(e.strerror)
return dest_dir return dest_dir

View File

@ -14,14 +14,17 @@
# You should have received a copy of the GNU Lesser General Public License # You should have received a copy of the GNU Lesser General Public License
# along with charm-helpers. If not, see <http://www.gnu.org/licenses/>. # along with charm-helpers. If not, see <http://www.gnu.org/licenses/>.
import amulet
import ConfigParser import ConfigParser
import distro_info
import io import io
import logging import logging
import os
import re import re
import six
import sys import sys
import time import time
import urlparse
import six
class AmuletUtils(object): class AmuletUtils(object):
@ -33,6 +36,7 @@ class AmuletUtils(object):
def __init__(self, log_level=logging.ERROR): def __init__(self, log_level=logging.ERROR):
self.log = self.get_logger(level=log_level) self.log = self.get_logger(level=log_level)
self.ubuntu_releases = self.get_ubuntu_releases()
def get_logger(self, name="amulet-logger", level=logging.DEBUG): def get_logger(self, name="amulet-logger", level=logging.DEBUG):
"""Get a logger object that will log to stdout.""" """Get a logger object that will log to stdout."""
@ -70,12 +74,44 @@ class AmuletUtils(object):
else: else:
return False return False
def validate_services(self, commands): def get_ubuntu_release_from_sentry(self, sentry_unit):
"""Validate services. """Get Ubuntu release codename from sentry unit.
Verify the specified services are running on the corresponding :param sentry_unit: amulet sentry/service unit pointer
:returns: list of strings - release codename, failure message
"""
msg = None
cmd = 'lsb_release -cs'
release, code = sentry_unit.run(cmd)
if code == 0:
self.log.debug('{} lsb_release: {}'.format(
sentry_unit.info['unit_name'], release))
else:
msg = ('{} `{}` returned {} '
'{}'.format(sentry_unit.info['unit_name'],
cmd, release, code))
if release not in self.ubuntu_releases:
msg = ("Release ({}) not found in Ubuntu releases "
"({})".format(release, self.ubuntu_releases))
return release, msg
def validate_services(self, commands):
"""Validate that lists of commands succeed on service units. Can be
used to verify system services are running on the corresponding
service units. service units.
"""
:param commands: dict with sentry keys and arbitrary command list vals
:returns: None if successful, Failure string message otherwise
"""
self.log.debug('Checking status of system services...')
# /!\ DEPRECATION WARNING (beisner):
# New and existing tests should be rewritten to use
# validate_services_by_name() as it is aware of init systems.
self.log.warn('/!\\ DEPRECATION WARNING: use '
'validate_services_by_name instead of validate_services '
'due to init system differences.')
for k, v in six.iteritems(commands): for k, v in six.iteritems(commands):
for cmd in v: for cmd in v:
output, code = k.run(cmd) output, code = k.run(cmd)
@ -86,6 +122,41 @@ class AmuletUtils(object):
return "command `{}` returned {}".format(cmd, str(code)) return "command `{}` returned {}".format(cmd, str(code))
return None return None
def validate_services_by_name(self, sentry_services):
"""Validate system service status by service name, automatically
detecting init system based on Ubuntu release codename.
:param sentry_services: dict with sentry keys and svc list values
:returns: None if successful, Failure string message otherwise
"""
self.log.debug('Checking status of system services...')
# Point at which systemd became a thing
systemd_switch = self.ubuntu_releases.index('vivid')
for sentry_unit, services_list in six.iteritems(sentry_services):
# Get lsb_release codename from unit
release, ret = self.get_ubuntu_release_from_sentry(sentry_unit)
if ret:
return ret
for service_name in services_list:
if (self.ubuntu_releases.index(release) >= systemd_switch or
service_name == "rabbitmq-server"):
# init is systemd
cmd = 'sudo service {} status'.format(service_name)
elif self.ubuntu_releases.index(release) < systemd_switch:
# init is upstart
cmd = 'sudo status {}'.format(service_name)
output, code = sentry_unit.run(cmd)
self.log.debug('{} `{}` returned '
'{}'.format(sentry_unit.info['unit_name'],
cmd, code))
if code != 0:
return "command `{}` returned {}".format(cmd, str(code))
return None
def _get_config(self, unit, filename): def _get_config(self, unit, filename):
"""Get a ConfigParser object for parsing a unit's config file.""" """Get a ConfigParser object for parsing a unit's config file."""
file_contents = unit.file_contents(filename) file_contents = unit.file_contents(filename)
@ -103,7 +174,15 @@ class AmuletUtils(object):
Verify that the specified section of the config file contains Verify that the specified section of the config file contains
the expected option key:value pairs. the expected option key:value pairs.
Compare expected dictionary data vs actual dictionary data.
The values in the 'expected' dictionary can be strings, bools, ints,
longs, or can be a function that evaluates a variable and returns a
bool.
""" """
self.log.debug('Validating config file data ({} in {} on {})'
'...'.format(section, config_file,
sentry_unit.info['unit_name']))
config = self._get_config(sentry_unit, config_file) config = self._get_config(sentry_unit, config_file)
if section != 'DEFAULT' and not config.has_section(section): if section != 'DEFAULT' and not config.has_section(section):
@ -112,9 +191,20 @@ class AmuletUtils(object):
for k in expected.keys(): for k in expected.keys():
if not config.has_option(section, k): if not config.has_option(section, k):
return "section [{}] is missing option {}".format(section, k) return "section [{}] is missing option {}".format(section, k)
if config.get(section, k) != expected[k]:
actual = config.get(section, k)
v = expected[k]
if (isinstance(v, six.string_types) or
isinstance(v, bool) or
isinstance(v, six.integer_types)):
# handle explicit values
if actual != v:
return "section [{}] {}:{} != expected {}:{}".format(
section, k, actual, k, expected[k])
# handle function pointers, such as not_null or valid_ip
elif not v(actual):
return "section [{}] {}:{} != expected {}:{}".format( return "section [{}] {}:{} != expected {}:{}".format(
section, k, config.get(section, k), k, expected[k]) section, k, actual, k, expected[k])
return None return None
def _validate_dict_data(self, expected, actual): def _validate_dict_data(self, expected, actual):
@ -122,7 +212,7 @@ class AmuletUtils(object):
Compare expected dictionary data vs actual dictionary data. Compare expected dictionary data vs actual dictionary data.
The values in the 'expected' dictionary can be strings, bools, ints, The values in the 'expected' dictionary can be strings, bools, ints,
longs, or can be a function that evaluate a variable and returns a longs, or can be a function that evaluates a variable and returns a
bool. bool.
""" """
self.log.debug('actual: {}'.format(repr(actual))) self.log.debug('actual: {}'.format(repr(actual)))
@ -133,8 +223,10 @@ class AmuletUtils(object):
if (isinstance(v, six.string_types) or if (isinstance(v, six.string_types) or
isinstance(v, bool) or isinstance(v, bool) or
isinstance(v, six.integer_types)): isinstance(v, six.integer_types)):
# handle explicit values
if v != actual[k]: if v != actual[k]:
return "{}:{}".format(k, actual[k]) return "{}:{}".format(k, actual[k])
# handle function pointers, such as not_null or valid_ip
elif not v(actual[k]): elif not v(actual[k]):
return "{}:{}".format(k, actual[k]) return "{}:{}".format(k, actual[k])
else: else:
@ -321,3 +413,121 @@ class AmuletUtils(object):
def endpoint_error(self, name, data): def endpoint_error(self, name, data):
return 'unexpected endpoint data in {} - {}'.format(name, data) return 'unexpected endpoint data in {} - {}'.format(name, data)
def get_ubuntu_releases(self):
"""Return a list of all Ubuntu releases in order of release."""
_d = distro_info.UbuntuDistroInfo()
_release_list = _d.all
self.log.debug('Ubuntu release list: {}'.format(_release_list))
return _release_list
def file_to_url(self, file_rel_path):
"""Convert a relative file path to a file URL."""
_abs_path = os.path.abspath(file_rel_path)
return urlparse.urlparse(_abs_path, scheme='file').geturl()
def check_commands_on_units(self, commands, sentry_units):
"""Check that all commands in a list exit zero on all
sentry units in a list.
:param commands: list of bash commands
:param sentry_units: list of sentry unit pointers
:returns: None if successful; Failure message otherwise
"""
self.log.debug('Checking exit codes for {} commands on {} '
'sentry units...'.format(len(commands),
len(sentry_units)))
for sentry_unit in sentry_units:
for cmd in commands:
output, code = sentry_unit.run(cmd)
if code == 0:
self.log.debug('{} `{}` returned {} '
'(OK)'.format(sentry_unit.info['unit_name'],
cmd, code))
else:
return ('{} `{}` returned {} '
'{}'.format(sentry_unit.info['unit_name'],
cmd, code, output))
return None
def get_process_id_list(self, sentry_unit, process_name):
"""Get a list of process ID(s) from a single sentry juju unit
for a single process name.
:param sentry_unit: Pointer to amulet sentry instance (juju unit)
:param process_name: Process name
:returns: List of process IDs
"""
cmd = 'pidof {}'.format(process_name)
output, code = sentry_unit.run(cmd)
if code != 0:
msg = ('{} `{}` returned {} '
'{}'.format(sentry_unit.info['unit_name'],
cmd, code, output))
amulet.raise_status(amulet.FAIL, msg=msg)
return str(output).split()
def get_unit_process_ids(self, unit_processes):
"""Construct a dict containing unit sentries, process names, and
process IDs."""
pid_dict = {}
for sentry_unit, process_list in unit_processes.iteritems():
pid_dict[sentry_unit] = {}
for process in process_list:
pids = self.get_process_id_list(sentry_unit, process)
pid_dict[sentry_unit].update({process: pids})
return pid_dict
def validate_unit_process_ids(self, expected, actual):
"""Validate process id quantities for services on units."""
self.log.debug('Checking units for running processes...')
self.log.debug('Expected PIDs: {}'.format(expected))
self.log.debug('Actual PIDs: {}'.format(actual))
if len(actual) != len(expected):
return ('Unit count mismatch. expected, actual: {}, '
'{} '.format(len(expected), len(actual)))
for (e_sentry, e_proc_names) in expected.iteritems():
e_sentry_name = e_sentry.info['unit_name']
if e_sentry in actual.keys():
a_proc_names = actual[e_sentry]
else:
return ('Expected sentry ({}) not found in actual dict data.'
'{}'.format(e_sentry_name, e_sentry))
if len(e_proc_names.keys()) != len(a_proc_names.keys()):
return ('Process name count mismatch. expected, actual: {}, '
'{}'.format(len(expected), len(actual)))
for (e_proc_name, e_pids_length), (a_proc_name, a_pids) in \
zip(e_proc_names.items(), a_proc_names.items()):
if e_proc_name != a_proc_name:
return ('Process name mismatch. expected, actual: {}, '
'{}'.format(e_proc_name, a_proc_name))
a_pids_length = len(a_pids)
if e_pids_length != a_pids_length:
return ('PID count mismatch. {} ({}) expected, actual: '
'{}, {} ({})'.format(e_sentry_name, e_proc_name,
e_pids_length, a_pids_length,
a_pids))
else:
self.log.debug('PID check OK: {} {} {}: '
'{}'.format(e_sentry_name, e_proc_name,
e_pids_length, a_pids))
return None
def validate_list_of_identical_dicts(self, list_of_dicts):
"""Check that all dicts within a list are identical."""
hashes = []
for _dict in list_of_dicts:
hashes.append(hash(frozenset(_dict.items())))
self.log.debug('Hashes: {}'.format(hashes))
if len(set(hashes)) == 1:
self.log.debug('Dicts within list are identical')
else:
return 'Dicts within list are not identical'
return None

View File

@ -79,9 +79,9 @@ class OpenStackAmuletDeployment(AmuletDeployment):
services.append(this_service) services.append(this_service)
use_source = ['mysql', 'mongodb', 'rabbitmq-server', 'ceph', use_source = ['mysql', 'mongodb', 'rabbitmq-server', 'ceph',
'ceph-osd', 'ceph-radosgw'] 'ceph-osd', 'ceph-radosgw']
# Openstack subordinate charms do not expose an origin option as that # Most OpenStack subordinate charms do not expose an origin option
# is controlled by the principle # as that is controlled by the principle.
ignore = ['neutron-openvswitch'] ignore = ['cinder-ceph', 'hacluster', 'neutron-openvswitch']
if self.openstack: if self.openstack:
for svc in services: for svc in services:
@ -110,7 +110,8 @@ class OpenStackAmuletDeployment(AmuletDeployment):
(self.precise_essex, self.precise_folsom, self.precise_grizzly, (self.precise_essex, self.precise_folsom, self.precise_grizzly,
self.precise_havana, self.precise_icehouse, self.precise_havana, self.precise_icehouse,
self.trusty_icehouse, self.trusty_juno, self.utopic_juno, self.trusty_icehouse, self.trusty_juno, self.utopic_juno,
self.trusty_kilo, self.vivid_kilo) = range(10) self.trusty_kilo, self.vivid_kilo, self.trusty_liberty,
self.wily_liberty) = range(12)
releases = { releases = {
('precise', None): self.precise_essex, ('precise', None): self.precise_essex,
@ -121,8 +122,10 @@ class OpenStackAmuletDeployment(AmuletDeployment):
('trusty', None): self.trusty_icehouse, ('trusty', None): self.trusty_icehouse,
('trusty', 'cloud:trusty-juno'): self.trusty_juno, ('trusty', 'cloud:trusty-juno'): self.trusty_juno,
('trusty', 'cloud:trusty-kilo'): self.trusty_kilo, ('trusty', 'cloud:trusty-kilo'): self.trusty_kilo,
('trusty', 'cloud:trusty-liberty'): self.trusty_liberty,
('utopic', None): self.utopic_juno, ('utopic', None): self.utopic_juno,
('vivid', None): self.vivid_kilo} ('vivid', None): self.vivid_kilo,
('wily', None): self.wily_liberty}
return releases[(self.series, self.openstack)] return releases[(self.series, self.openstack)]
def _get_openstack_release_string(self): def _get_openstack_release_string(self):
@ -138,9 +141,43 @@ class OpenStackAmuletDeployment(AmuletDeployment):
('trusty', 'icehouse'), ('trusty', 'icehouse'),
('utopic', 'juno'), ('utopic', 'juno'),
('vivid', 'kilo'), ('vivid', 'kilo'),
('wily', 'liberty'),
]) ])
if self.openstack: if self.openstack:
os_origin = self.openstack.split(':')[1] os_origin = self.openstack.split(':')[1]
return os_origin.split('%s-' % self.series)[1].split('/')[0] return os_origin.split('%s-' % self.series)[1].split('/')[0]
else: else:
return releases[self.series] return releases[self.series]
def get_ceph_expected_pools(self, radosgw=False):
"""Return a list of expected ceph pools in a ceph + cinder + glance
test scenario, based on OpenStack release and whether ceph radosgw
is flagged as present or not."""
if self._get_openstack_release() >= self.trusty_kilo:
# Kilo or later
pools = [
'rbd',
'cinder',
'glance'
]
else:
# Juno or earlier
pools = [
'data',
'metadata',
'rbd',
'cinder',
'glance'
]
if radosgw:
pools.extend([
'.rgw.root',
'.rgw.control',
'.rgw',
'.rgw.gc',
'.users.uid'
])
return pools

View File

@ -14,16 +14,20 @@
# You should have received a copy of the GNU Lesser General Public License # You should have received a copy of the GNU Lesser General Public License
# along with charm-helpers. If not, see <http://www.gnu.org/licenses/>. # along with charm-helpers. If not, see <http://www.gnu.org/licenses/>.
import amulet
import json
import logging import logging
import os import os
import six
import time import time
import urllib import urllib
import cinderclient.v1.client as cinder_client
import glanceclient.v1.client as glance_client import glanceclient.v1.client as glance_client
import heatclient.v1.client as heat_client
import keystoneclient.v2_0 as keystone_client import keystoneclient.v2_0 as keystone_client
import novaclient.v1_1.client as nova_client import novaclient.v1_1.client as nova_client
import swiftclient
import six
from charmhelpers.contrib.amulet.utils import ( from charmhelpers.contrib.amulet.utils import (
AmuletUtils AmuletUtils
@ -37,7 +41,7 @@ class OpenStackAmuletUtils(AmuletUtils):
"""OpenStack amulet utilities. """OpenStack amulet utilities.
This class inherits from AmuletUtils and has additional support This class inherits from AmuletUtils and has additional support
that is specifically for use by OpenStack charms. that is specifically for use by OpenStack charm tests.
""" """
def __init__(self, log_level=ERROR): def __init__(self, log_level=ERROR):
@ -51,6 +55,8 @@ class OpenStackAmuletUtils(AmuletUtils):
Validate actual endpoint data vs expected endpoint data. The ports Validate actual endpoint data vs expected endpoint data. The ports
are used to find the matching endpoint. are used to find the matching endpoint.
""" """
self.log.debug('Validating endpoint data...')
self.log.debug('actual: {}'.format(repr(endpoints)))
found = False found = False
for ep in endpoints: for ep in endpoints:
self.log.debug('endpoint: {}'.format(repr(ep))) self.log.debug('endpoint: {}'.format(repr(ep)))
@ -77,6 +83,7 @@ class OpenStackAmuletUtils(AmuletUtils):
Validate a list of actual service catalog endpoints vs a list of Validate a list of actual service catalog endpoints vs a list of
expected service catalog endpoints. expected service catalog endpoints.
""" """
self.log.debug('Validating service catalog endpoint data...')
self.log.debug('actual: {}'.format(repr(actual))) self.log.debug('actual: {}'.format(repr(actual)))
for k, v in six.iteritems(expected): for k, v in six.iteritems(expected):
if k in actual: if k in actual:
@ -93,6 +100,7 @@ class OpenStackAmuletUtils(AmuletUtils):
Validate a list of actual tenant data vs list of expected tenant Validate a list of actual tenant data vs list of expected tenant
data. data.
""" """
self.log.debug('Validating tenant data...')
self.log.debug('actual: {}'.format(repr(actual))) self.log.debug('actual: {}'.format(repr(actual)))
for e in expected: for e in expected:
found = False found = False
@ -114,6 +122,7 @@ class OpenStackAmuletUtils(AmuletUtils):
Validate a list of actual role data vs a list of expected role Validate a list of actual role data vs a list of expected role
data. data.
""" """
self.log.debug('Validating role data...')
self.log.debug('actual: {}'.format(repr(actual))) self.log.debug('actual: {}'.format(repr(actual)))
for e in expected: for e in expected:
found = False found = False
@ -134,6 +143,7 @@ class OpenStackAmuletUtils(AmuletUtils):
Validate a list of actual user data vs a list of expected user Validate a list of actual user data vs a list of expected user
data. data.
""" """
self.log.debug('Validating user data...')
self.log.debug('actual: {}'.format(repr(actual))) self.log.debug('actual: {}'.format(repr(actual)))
for e in expected: for e in expected:
found = False found = False
@ -155,17 +165,30 @@ class OpenStackAmuletUtils(AmuletUtils):
Validate a list of actual flavors vs a list of expected flavors. Validate a list of actual flavors vs a list of expected flavors.
""" """
self.log.debug('Validating flavor data...')
self.log.debug('actual: {}'.format(repr(actual))) self.log.debug('actual: {}'.format(repr(actual)))
act = [a.name for a in actual] act = [a.name for a in actual]
return self._validate_list_data(expected, act) return self._validate_list_data(expected, act)
def tenant_exists(self, keystone, tenant): def tenant_exists(self, keystone, tenant):
"""Return True if tenant exists.""" """Return True if tenant exists."""
self.log.debug('Checking if tenant exists ({})...'.format(tenant))
return tenant in [t.name for t in keystone.tenants.list()] return tenant in [t.name for t in keystone.tenants.list()]
def authenticate_cinder_admin(self, keystone_sentry, username,
password, tenant):
"""Authenticates admin user with cinder."""
# NOTE(beisner): cinder python client doesn't accept tokens.
service_ip = \
keystone_sentry.relation('shared-db',
'mysql:shared-db')['private-address']
ept = "http://{}:5000/v2.0".format(service_ip.strip().decode('utf-8'))
return cinder_client.Client(username, password, tenant, ept)
def authenticate_keystone_admin(self, keystone_sentry, user, password, def authenticate_keystone_admin(self, keystone_sentry, user, password,
tenant): tenant):
"""Authenticates admin user with the keystone admin endpoint.""" """Authenticates admin user with the keystone admin endpoint."""
self.log.debug('Authenticating keystone admin...')
unit = keystone_sentry unit = keystone_sentry
service_ip = unit.relation('shared-db', service_ip = unit.relation('shared-db',
'mysql:shared-db')['private-address'] 'mysql:shared-db')['private-address']
@ -175,6 +198,7 @@ class OpenStackAmuletUtils(AmuletUtils):
def authenticate_keystone_user(self, keystone, user, password, tenant): def authenticate_keystone_user(self, keystone, user, password, tenant):
"""Authenticates a regular user with the keystone public endpoint.""" """Authenticates a regular user with the keystone public endpoint."""
self.log.debug('Authenticating keystone user ({})...'.format(user))
ep = keystone.service_catalog.url_for(service_type='identity', ep = keystone.service_catalog.url_for(service_type='identity',
endpoint_type='publicURL') endpoint_type='publicURL')
return keystone_client.Client(username=user, password=password, return keystone_client.Client(username=user, password=password,
@ -182,19 +206,49 @@ class OpenStackAmuletUtils(AmuletUtils):
def authenticate_glance_admin(self, keystone): def authenticate_glance_admin(self, keystone):
"""Authenticates admin user with glance.""" """Authenticates admin user with glance."""
self.log.debug('Authenticating glance admin...')
ep = keystone.service_catalog.url_for(service_type='image', ep = keystone.service_catalog.url_for(service_type='image',
endpoint_type='adminURL') endpoint_type='adminURL')
return glance_client.Client(ep, token=keystone.auth_token) return glance_client.Client(ep, token=keystone.auth_token)
def authenticate_heat_admin(self, keystone):
"""Authenticates the admin user with heat."""
self.log.debug('Authenticating heat admin...')
ep = keystone.service_catalog.url_for(service_type='orchestration',
endpoint_type='publicURL')
return heat_client.Client(endpoint=ep, token=keystone.auth_token)
def authenticate_nova_user(self, keystone, user, password, tenant): def authenticate_nova_user(self, keystone, user, password, tenant):
"""Authenticates a regular user with nova-api.""" """Authenticates a regular user with nova-api."""
self.log.debug('Authenticating nova user ({})...'.format(user))
ep = keystone.service_catalog.url_for(service_type='identity', ep = keystone.service_catalog.url_for(service_type='identity',
endpoint_type='publicURL') endpoint_type='publicURL')
return nova_client.Client(username=user, api_key=password, return nova_client.Client(username=user, api_key=password,
project_id=tenant, auth_url=ep) project_id=tenant, auth_url=ep)
def authenticate_swift_user(self, keystone, user, password, tenant):
"""Authenticates a regular user with swift api."""
self.log.debug('Authenticating swift user ({})...'.format(user))
ep = keystone.service_catalog.url_for(service_type='identity',
endpoint_type='publicURL')
return swiftclient.Connection(authurl=ep,
user=user,
key=password,
tenant_name=tenant,
auth_version='2.0')
def create_cirros_image(self, glance, image_name): def create_cirros_image(self, glance, image_name):
"""Download the latest cirros image and upload it to glance.""" """Download the latest cirros image and upload it to glance,
validate and return a resource pointer.
:param glance: pointer to authenticated glance connection
:param image_name: display name for new image
:returns: glance image pointer
"""
self.log.debug('Creating glance cirros image '
'({})...'.format(image_name))
# Download cirros image
http_proxy = os.getenv('AMULET_HTTP_PROXY') http_proxy = os.getenv('AMULET_HTTP_PROXY')
self.log.debug('AMULET_HTTP_PROXY: {}'.format(http_proxy)) self.log.debug('AMULET_HTTP_PROXY: {}'.format(http_proxy))
if http_proxy: if http_proxy:
@ -203,57 +257,67 @@ class OpenStackAmuletUtils(AmuletUtils):
else: else:
opener = urllib.FancyURLopener() opener = urllib.FancyURLopener()
f = opener.open("http://download.cirros-cloud.net/version/released") f = opener.open('http://download.cirros-cloud.net/version/released')
version = f.read().strip() version = f.read().strip()
cirros_img = "cirros-{}-x86_64-disk.img".format(version) cirros_img = 'cirros-{}-x86_64-disk.img'.format(version)
local_path = os.path.join('tests', cirros_img) local_path = os.path.join('tests', cirros_img)
if not os.path.exists(local_path): if not os.path.exists(local_path):
cirros_url = "http://{}/{}/{}".format("download.cirros-cloud.net", cirros_url = 'http://{}/{}/{}'.format('download.cirros-cloud.net',
version, cirros_img) version, cirros_img)
opener.retrieve(cirros_url, local_path) opener.retrieve(cirros_url, local_path)
f.close() f.close()
# Create glance image
with open(local_path) as f: with open(local_path) as f:
image = glance.images.create(name=image_name, is_public=True, image = glance.images.create(name=image_name, is_public=True,
disk_format='qcow2', disk_format='qcow2',
container_format='bare', data=f) container_format='bare', data=f)
count = 1
status = image.status
while status != 'active' and count < 10:
time.sleep(3)
image = glance.images.get(image.id)
status = image.status
self.log.debug('image status: {}'.format(status))
count += 1
if status != 'active': # Wait for image to reach active status
self.log.error('image creation timed out') img_id = image.id
return None ret = self.resource_reaches_status(glance.images, img_id,
expected_stat='active',
msg='Image status wait')
if not ret:
msg = 'Glance image failed to reach expected state.'
amulet.raise_status(amulet.FAIL, msg=msg)
# Re-validate new image
self.log.debug('Validating image attributes...')
val_img_name = glance.images.get(img_id).name
val_img_stat = glance.images.get(img_id).status
val_img_pub = glance.images.get(img_id).is_public
val_img_cfmt = glance.images.get(img_id).container_format
val_img_dfmt = glance.images.get(img_id).disk_format
msg_attr = ('Image attributes - name:{} public:{} id:{} stat:{} '
'container fmt:{} disk fmt:{}'.format(
val_img_name, val_img_pub, img_id,
val_img_stat, val_img_cfmt, val_img_dfmt))
if val_img_name == image_name and val_img_stat == 'active' \
and val_img_pub is True and val_img_cfmt == 'bare' \
and val_img_dfmt == 'qcow2':
self.log.debug(msg_attr)
else:
msg = ('Volume validation failed, {}'.format(msg_attr))
amulet.raise_status(amulet.FAIL, msg=msg)
return image return image
def delete_image(self, glance, image): def delete_image(self, glance, image):
"""Delete the specified image.""" """Delete the specified image."""
num_before = len(list(glance.images.list()))
glance.images.delete(image)
count = 1 # /!\ DEPRECATION WARNING
num_after = len(list(glance.images.list())) self.log.warn('/!\\ DEPRECATION WARNING: use '
while num_after != (num_before - 1) and count < 10: 'delete_resource instead of delete_image.')
time.sleep(3) self.log.debug('Deleting glance image ({})...'.format(image))
num_after = len(list(glance.images.list())) return self.delete_resource(glance.images, image, msg='glance image')
self.log.debug('number of images: {}'.format(num_after))
count += 1
if num_after != (num_before - 1):
self.log.error('image deletion timed out')
return False
return True
def create_instance(self, nova, image_name, instance_name, flavor): def create_instance(self, nova, image_name, instance_name, flavor):
"""Create the specified instance.""" """Create the specified instance."""
self.log.debug('Creating instance '
'({}|{}|{})'.format(instance_name, image_name, flavor))
image = nova.images.find(name=image_name) image = nova.images.find(name=image_name)
flavor = nova.flavors.find(name=flavor) flavor = nova.flavors.find(name=flavor)
instance = nova.servers.create(name=instance_name, image=image, instance = nova.servers.create(name=instance_name, image=image,
@ -276,19 +340,265 @@ class OpenStackAmuletUtils(AmuletUtils):
def delete_instance(self, nova, instance): def delete_instance(self, nova, instance):
"""Delete the specified instance.""" """Delete the specified instance."""
num_before = len(list(nova.servers.list()))
nova.servers.delete(instance)
count = 1 # /!\ DEPRECATION WARNING
num_after = len(list(nova.servers.list())) self.log.warn('/!\\ DEPRECATION WARNING: use '
while num_after != (num_before - 1) and count < 10: 'delete_resource instead of delete_instance.')
time.sleep(3) self.log.debug('Deleting instance ({})...'.format(instance))
num_after = len(list(nova.servers.list())) return self.delete_resource(nova.servers, instance,
self.log.debug('number of instances: {}'.format(num_after)) msg='nova instance')
count += 1
if num_after != (num_before - 1): def create_or_get_keypair(self, nova, keypair_name="testkey"):
self.log.error('instance deletion timed out') """Create a new keypair, or return pointer if it already exists."""
try:
_keypair = nova.keypairs.get(keypair_name)
self.log.debug('Keypair ({}) already exists, '
'using it.'.format(keypair_name))
return _keypair
except:
self.log.debug('Keypair ({}) does not exist, '
'creating it.'.format(keypair_name))
_keypair = nova.keypairs.create(name=keypair_name)
return _keypair
def create_cinder_volume(self, cinder, vol_name="demo-vol", vol_size=1,
img_id=None, src_vol_id=None, snap_id=None):
"""Create cinder volume, optionally from a glance image, OR
optionally as a clone of an existing volume, OR optionally
from a snapshot. Wait for the new volume status to reach
the expected status, validate and return a resource pointer.
:param vol_name: cinder volume display name
:param vol_size: size in gigabytes
:param img_id: optional glance image id
:param src_vol_id: optional source volume id to clone
:param snap_id: optional snapshot id to use
:returns: cinder volume pointer
"""
# Handle parameter input and avoid impossible combinations
if img_id and not src_vol_id and not snap_id:
# Create volume from image
self.log.debug('Creating cinder volume from glance image...')
bootable = 'true'
elif src_vol_id and not img_id and not snap_id:
# Clone an existing volume
self.log.debug('Cloning cinder volume...')
bootable = cinder.volumes.get(src_vol_id).bootable
elif snap_id and not src_vol_id and not img_id:
# Create volume from snapshot
self.log.debug('Creating cinder volume from snapshot...')
snap = cinder.volume_snapshots.find(id=snap_id)
vol_size = snap.size
snap_vol_id = cinder.volume_snapshots.get(snap_id).volume_id
bootable = cinder.volumes.get(snap_vol_id).bootable
elif not img_id and not src_vol_id and not snap_id:
# Create volume
self.log.debug('Creating cinder volume...')
bootable = 'false'
else:
# Impossible combination of parameters
msg = ('Invalid method use - name:{} size:{} img_id:{} '
'src_vol_id:{} snap_id:{}'.format(vol_name, vol_size,
img_id, src_vol_id,
snap_id))
amulet.raise_status(amulet.FAIL, msg=msg)
# Create new volume
try:
vol_new = cinder.volumes.create(display_name=vol_name,
imageRef=img_id,
size=vol_size,
source_volid=src_vol_id,
snapshot_id=snap_id)
vol_id = vol_new.id
except Exception as e:
msg = 'Failed to create volume: {}'.format(e)
amulet.raise_status(amulet.FAIL, msg=msg)
# Wait for volume to reach available status
ret = self.resource_reaches_status(cinder.volumes, vol_id,
expected_stat="available",
msg="Volume status wait")
if not ret:
msg = 'Cinder volume failed to reach expected state.'
amulet.raise_status(amulet.FAIL, msg=msg)
# Re-validate new volume
self.log.debug('Validating volume attributes...')
val_vol_name = cinder.volumes.get(vol_id).display_name
val_vol_boot = cinder.volumes.get(vol_id).bootable
val_vol_stat = cinder.volumes.get(vol_id).status
val_vol_size = cinder.volumes.get(vol_id).size
msg_attr = ('Volume attributes - name:{} id:{} stat:{} boot:'
'{} size:{}'.format(val_vol_name, vol_id,
val_vol_stat, val_vol_boot,
val_vol_size))
if val_vol_boot == bootable and val_vol_stat == 'available' \
and val_vol_name == vol_name and val_vol_size == vol_size:
self.log.debug(msg_attr)
else:
msg = ('Volume validation failed, {}'.format(msg_attr))
amulet.raise_status(amulet.FAIL, msg=msg)
return vol_new
def delete_resource(self, resource, resource_id,
msg="resource", max_wait=120):
"""Delete one openstack resource, such as one instance, keypair,
image, volume, stack, etc., and confirm deletion within max wait time.
:param resource: pointer to os resource type, ex:glance_client.images
:param resource_id: unique name or id for the openstack resource
:param msg: text to identify purpose in logging
:param max_wait: maximum wait time in seconds
:returns: True if successful, otherwise False
"""
self.log.debug('Deleting OpenStack resource '
'{} ({})'.format(resource_id, msg))
num_before = len(list(resource.list()))
resource.delete(resource_id)
tries = 0
num_after = len(list(resource.list()))
while num_after != (num_before - 1) and tries < (max_wait / 4):
self.log.debug('{} delete check: '
'{} [{}:{}] {}'.format(msg, tries,
num_before,
num_after,
resource_id))
time.sleep(4)
num_after = len(list(resource.list()))
tries += 1
self.log.debug('{}: expected, actual count = {}, '
'{}'.format(msg, num_before - 1, num_after))
if num_after == (num_before - 1):
return True
else:
self.log.error('{} delete timed out'.format(msg))
return False return False
return True def resource_reaches_status(self, resource, resource_id,
expected_stat='available',
msg='resource', max_wait=120):
"""Wait for an openstack resources status to reach an
expected status within a specified time. Useful to confirm that
nova instances, cinder vols, snapshots, glance images, heat stacks
and other resources eventually reach the expected status.
:param resource: pointer to os resource type, ex: heat_client.stacks
:param resource_id: unique id for the openstack resource
:param expected_stat: status to expect resource to reach
:param msg: text to identify purpose in logging
:param max_wait: maximum wait time in seconds
:returns: True if successful, False if status is not reached
"""
tries = 0
resource_stat = resource.get(resource_id).status
while resource_stat != expected_stat and tries < (max_wait / 4):
self.log.debug('{} status check: '
'{} [{}:{}] {}'.format(msg, tries,
resource_stat,
expected_stat,
resource_id))
time.sleep(4)
resource_stat = resource.get(resource_id).status
tries += 1
self.log.debug('{}: expected, actual status = {}, '
'{}'.format(msg, resource_stat, expected_stat))
if resource_stat == expected_stat:
return True
else:
self.log.debug('{} never reached expected status: '
'{}'.format(resource_id, expected_stat))
return False
def get_ceph_osd_id_cmd(self, index):
"""Produce a shell command that will return a ceph-osd id."""
return ("`initctl list | grep 'ceph-osd ' | "
"awk 'NR=={} {{ print $2 }}' | "
"grep -o '[0-9]*'`".format(index + 1))
def get_ceph_pools(self, sentry_unit):
"""Return a dict of ceph pools from a single ceph unit, with
pool name as keys, pool id as vals."""
pools = {}
cmd = 'sudo ceph osd lspools'
output, code = sentry_unit.run(cmd)
if code != 0:
msg = ('{} `{}` returned {} '
'{}'.format(sentry_unit.info['unit_name'],
cmd, code, output))
amulet.raise_status(amulet.FAIL, msg=msg)
# Example output: 0 data,1 metadata,2 rbd,3 cinder,4 glance,
for pool in str(output).split(','):
pool_id_name = pool.split(' ')
if len(pool_id_name) == 2:
pool_id = pool_id_name[0]
pool_name = pool_id_name[1]
pools[pool_name] = int(pool_id)
self.log.debug('Pools on {}: {}'.format(sentry_unit.info['unit_name'],
pools))
return pools
def get_ceph_df(self, sentry_unit):
"""Return dict of ceph df json output, including ceph pool state.
:param sentry_unit: Pointer to amulet sentry instance (juju unit)
:returns: Dict of ceph df output
"""
cmd = 'sudo ceph df --format=json'
output, code = sentry_unit.run(cmd)
if code != 0:
msg = ('{} `{}` returned {} '
'{}'.format(sentry_unit.info['unit_name'],
cmd, code, output))
amulet.raise_status(amulet.FAIL, msg=msg)
return json.loads(output)
def get_ceph_pool_sample(self, sentry_unit, pool_id=0):
"""Take a sample of attributes of a ceph pool, returning ceph
pool name, object count and disk space used for the specified
pool ID number.
:param sentry_unit: Pointer to amulet sentry instance (juju unit)
:param pool_id: Ceph pool ID
:returns: List of pool name, object count, kb disk space used
"""
df = self.get_ceph_df(sentry_unit)
pool_name = df['pools'][pool_id]['name']
obj_count = df['pools'][pool_id]['stats']['objects']
kb_used = df['pools'][pool_id]['stats']['kb_used']
self.log.debug('Ceph {} pool (ID {}): {} objects, '
'{} kb used'.format(pool_name, pool_id,
obj_count, kb_used))
return pool_name, obj_count, kb_used
def validate_ceph_pool_samples(self, samples, sample_type="resource pool"):
"""Validate ceph pool samples taken over time, such as pool
object counts or pool kb used, before adding, after adding, and
after deleting items which affect those pool attributes. The
2nd element is expected to be greater than the 1st; 3rd is expected
to be less than the 2nd.
:param samples: List containing 3 data samples
:param sample_type: String for logging and usage context
:returns: None if successful, Failure message otherwise
"""
original, created, deleted = range(3)
if samples[created] <= samples[original] or \
samples[deleted] >= samples[created]:
return ('Ceph {} samples ({}) '
'unexpected.'.format(sample_type, samples))
else:
self.log.debug('Ceph {} samples (OK): '
'{}'.format(sample_type, samples))
return None