Update requirements
Also remove tests/charm-helpers if present, in favor of the pip-installed charm-helpers. Change-Id: I4898218e3d8647d174ceff7212de798450790eb8
This commit is contained in:
parent
82ac621a00
commit
bca33c0f9f
@ -2,7 +2,6 @@
|
|||||||
# of appearance. Changing the order has an impact on the overall integration
|
# of appearance. Changing the order has an impact on the overall integration
|
||||||
# process, which may cause wedges in the gate later.
|
# process, which may cause wedges in the gate later.
|
||||||
pbr>=1.8.0,<1.9.0
|
pbr>=1.8.0,<1.9.0
|
||||||
PyYAML>=3.1.0
|
|
||||||
simplejson>=2.2.0
|
simplejson>=2.2.0
|
||||||
netifaces>=0.10.4
|
netifaces>=0.10.4
|
||||||
netaddr>=0.7.12,!=0.7.16
|
netaddr>=0.7.12,!=0.7.16
|
||||||
|
@ -1,18 +1,16 @@
|
|||||||
# The order of packages is significant, because pip processes them in the order
|
# The order of packages is significant, because pip processes them in the order
|
||||||
# of appearance. Changing the order has an impact on the overall integration
|
# of appearance. Changing the order has an impact on the overall integration
|
||||||
# process, which may cause wedges in the gate later.
|
# process, which may cause wedges in the gate later.
|
||||||
|
charm-tools>=2.4.4
|
||||||
coverage>=3.6
|
coverage>=3.6
|
||||||
mock>=1.2
|
mock>=1.2
|
||||||
flake8>=2.2.4,<=2.4.1
|
flake8>=2.2.4,<=2.4.1
|
||||||
os-testr>=0.4.1
|
os-testr>=0.4.1
|
||||||
charm-tools>=2.0.0
|
requests>=2.18.4
|
||||||
requests==2.6.0
|
|
||||||
cmd2!=0.8.3,<0.9.0;python_version<'3.0' # MIT
|
|
||||||
cmd2!=0.8.3;python_version>='3.0' # MIT
|
|
||||||
# BEGIN: Amulet OpenStack Charm Helper Requirements
|
# BEGIN: Amulet OpenStack Charm Helper Requirements
|
||||||
# Liberty client lower constraints
|
# Liberty client lower constraints
|
||||||
amulet>=1.14.3,<2.0
|
amulet>=1.14.3,<2.0;python_version=='2.7'
|
||||||
bundletester>=0.6.1,<1.0
|
bundletester>=0.6.1,<1.0;python_version=='2.7'
|
||||||
python-ceilometerclient>=1.5.0
|
python-ceilometerclient>=1.5.0
|
||||||
python-cinderclient>=1.4.0
|
python-cinderclient>=1.4.0
|
||||||
python-glanceclient>=1.1.0
|
python-glanceclient>=1.1.0
|
||||||
@ -24,6 +22,8 @@ python-openstackclient>=1.7.0
|
|||||||
python-swiftclient>=2.6.0
|
python-swiftclient>=2.6.0
|
||||||
pika>=0.10.0,<1.0
|
pika>=0.10.0,<1.0
|
||||||
distro-info
|
distro-info
|
||||||
|
git+https://github.com/juju/charm-helpers.git#egg=charmhelpers
|
||||||
# END: Amulet OpenStack Charm Helper Requirements
|
# END: Amulet OpenStack Charm Helper Requirements
|
||||||
# NOTE: workaround for 14.04 pip/tox
|
# NOTE: workaround for 14.04 pip/tox
|
||||||
pytz
|
pytz
|
||||||
|
pyudev # for ceph-* charm unit tests (not mocked?)
|
||||||
|
@ -1,97 +0,0 @@
|
|||||||
# Copyright 2014-2015 Canonical Limited.
|
|
||||||
#
|
|
||||||
# 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.
|
|
||||||
|
|
||||||
# Bootstrap charm-helpers, installing its dependencies if necessary using
|
|
||||||
# only standard libraries.
|
|
||||||
from __future__ import print_function
|
|
||||||
from __future__ import absolute_import
|
|
||||||
|
|
||||||
import functools
|
|
||||||
import inspect
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
|
|
||||||
try:
|
|
||||||
import six # flake8: noqa
|
|
||||||
except ImportError:
|
|
||||||
if sys.version_info.major == 2:
|
|
||||||
subprocess.check_call(['apt-get', 'install', '-y', 'python-six'])
|
|
||||||
else:
|
|
||||||
subprocess.check_call(['apt-get', 'install', '-y', 'python3-six'])
|
|
||||||
import six # flake8: noqa
|
|
||||||
|
|
||||||
try:
|
|
||||||
import yaml # flake8: noqa
|
|
||||||
except ImportError:
|
|
||||||
if sys.version_info.major == 2:
|
|
||||||
subprocess.check_call(['apt-get', 'install', '-y', 'python-yaml'])
|
|
||||||
else:
|
|
||||||
subprocess.check_call(['apt-get', 'install', '-y', 'python3-yaml'])
|
|
||||||
import yaml # flake8: noqa
|
|
||||||
|
|
||||||
|
|
||||||
# Holds a list of mapping of mangled function names that have been deprecated
|
|
||||||
# using the @deprecate decorator below. This is so that the warning is only
|
|
||||||
# printed once for each usage of the function.
|
|
||||||
__deprecated_functions = {}
|
|
||||||
|
|
||||||
|
|
||||||
def deprecate(warning, date=None, log=None):
|
|
||||||
"""Add a deprecation warning the first time the function is used.
|
|
||||||
The date, which is a string in semi-ISO8660 format indicate the year-month
|
|
||||||
that the function is officially going to be removed.
|
|
||||||
|
|
||||||
usage:
|
|
||||||
|
|
||||||
@deprecate('use core/fetch/add_source() instead', '2017-04')
|
|
||||||
def contributed_add_source_thing(...):
|
|
||||||
...
|
|
||||||
|
|
||||||
And it then prints to the log ONCE that the function is deprecated.
|
|
||||||
The reason for passing the logging function (log) is so that hookenv.log
|
|
||||||
can be used for a charm if needed.
|
|
||||||
|
|
||||||
:param warning: String to indicat where it has moved ot.
|
|
||||||
:param date: optional sting, in YYYY-MM format to indicate when the
|
|
||||||
function will definitely (probably) be removed.
|
|
||||||
:param log: The log function to call to log. If not, logs to stdout
|
|
||||||
"""
|
|
||||||
def wrap(f):
|
|
||||||
|
|
||||||
@functools.wraps(f)
|
|
||||||
def wrapped_f(*args, **kwargs):
|
|
||||||
try:
|
|
||||||
module = inspect.getmodule(f)
|
|
||||||
file = inspect.getsourcefile(f)
|
|
||||||
lines = inspect.getsourcelines(f)
|
|
||||||
f_name = "{}-{}-{}..{}-{}".format(
|
|
||||||
module.__name__, file, lines[0], lines[-1], f.__name__)
|
|
||||||
except (IOError, TypeError):
|
|
||||||
# assume it was local, so just use the name of the function
|
|
||||||
f_name = f.__name__
|
|
||||||
if f_name not in __deprecated_functions:
|
|
||||||
__deprecated_functions[f_name] = True
|
|
||||||
s = "DEPRECATION WARNING: Function {} is being removed".format(
|
|
||||||
f.__name__)
|
|
||||||
if date:
|
|
||||||
s = "{} on/around {}".format(s, date)
|
|
||||||
if warning:
|
|
||||||
s = "{} : {}".format(s, warning)
|
|
||||||
if log:
|
|
||||||
log(s)
|
|
||||||
else:
|
|
||||||
print(s)
|
|
||||||
return f(*args, **kwargs)
|
|
||||||
return wrapped_f
|
|
||||||
return wrap
|
|
@ -1,13 +0,0 @@
|
|||||||
# Copyright 2014-2015 Canonical Limited.
|
|
||||||
#
|
|
||||||
# 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.
|
|
@ -1,13 +0,0 @@
|
|||||||
# Copyright 2014-2015 Canonical Limited.
|
|
||||||
#
|
|
||||||
# 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.
|
|
@ -1,99 +0,0 @@
|
|||||||
# Copyright 2014-2015 Canonical Limited.
|
|
||||||
#
|
|
||||||
# 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.
|
|
||||||
|
|
||||||
import amulet
|
|
||||||
import os
|
|
||||||
import six
|
|
||||||
|
|
||||||
|
|
||||||
class AmuletDeployment(object):
|
|
||||||
"""Amulet deployment.
|
|
||||||
|
|
||||||
This class provides generic Amulet deployment and test runner
|
|
||||||
methods.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, series=None):
|
|
||||||
"""Initialize the deployment environment."""
|
|
||||||
self.series = None
|
|
||||||
|
|
||||||
if series:
|
|
||||||
self.series = series
|
|
||||||
self.d = amulet.Deployment(series=self.series)
|
|
||||||
else:
|
|
||||||
self.d = amulet.Deployment()
|
|
||||||
|
|
||||||
def _add_services(self, this_service, other_services):
|
|
||||||
"""Add services.
|
|
||||||
|
|
||||||
Add services to the deployment where this_service is the local charm
|
|
||||||
that we're testing and other_services are the other services that
|
|
||||||
are being used in the local amulet tests.
|
|
||||||
"""
|
|
||||||
if this_service['name'] != os.path.basename(os.getcwd()):
|
|
||||||
s = this_service['name']
|
|
||||||
msg = "The charm's root directory name needs to be {}".format(s)
|
|
||||||
amulet.raise_status(amulet.FAIL, msg=msg)
|
|
||||||
|
|
||||||
if 'units' not in this_service:
|
|
||||||
this_service['units'] = 1
|
|
||||||
|
|
||||||
self.d.add(this_service['name'], units=this_service['units'],
|
|
||||||
constraints=this_service.get('constraints'),
|
|
||||||
storage=this_service.get('storage'))
|
|
||||||
|
|
||||||
for svc in other_services:
|
|
||||||
if 'location' in svc:
|
|
||||||
branch_location = svc['location']
|
|
||||||
elif self.series:
|
|
||||||
branch_location = 'cs:{}/{}'.format(self.series, svc['name']),
|
|
||||||
else:
|
|
||||||
branch_location = None
|
|
||||||
|
|
||||||
if 'units' not in svc:
|
|
||||||
svc['units'] = 1
|
|
||||||
|
|
||||||
self.d.add(svc['name'], charm=branch_location, units=svc['units'],
|
|
||||||
constraints=svc.get('constraints'),
|
|
||||||
storage=svc.get('storage'))
|
|
||||||
|
|
||||||
def _add_relations(self, relations):
|
|
||||||
"""Add all of the relations for the services."""
|
|
||||||
for k, v in six.iteritems(relations):
|
|
||||||
self.d.relate(k, v)
|
|
||||||
|
|
||||||
def _configure_services(self, configs):
|
|
||||||
"""Configure all of the services."""
|
|
||||||
for service, config in six.iteritems(configs):
|
|
||||||
self.d.configure(service, config)
|
|
||||||
|
|
||||||
def _deploy(self):
|
|
||||||
"""Deploy environment and wait for all hooks to finish executing."""
|
|
||||||
timeout = int(os.environ.get('AMULET_SETUP_TIMEOUT', 900))
|
|
||||||
try:
|
|
||||||
self.d.setup(timeout=timeout)
|
|
||||||
self.d.sentry.wait(timeout=timeout)
|
|
||||||
except amulet.helpers.TimeoutError:
|
|
||||||
amulet.raise_status(
|
|
||||||
amulet.FAIL,
|
|
||||||
msg="Deployment timed out ({}s)".format(timeout)
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
raise
|
|
||||||
|
|
||||||
def run_tests(self):
|
|
||||||
"""Run all of the methods that are prefixed with 'test_'."""
|
|
||||||
for test in dir(self):
|
|
||||||
if test.startswith('test_'):
|
|
||||||
getattr(self, test)()
|
|
@ -1,821 +0,0 @@
|
|||||||
# Copyright 2014-2015 Canonical Limited.
|
|
||||||
#
|
|
||||||
# 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.
|
|
||||||
|
|
||||||
import io
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import socket
|
|
||||||
import subprocess
|
|
||||||
import sys
|
|
||||||
import time
|
|
||||||
import uuid
|
|
||||||
|
|
||||||
import amulet
|
|
||||||
import distro_info
|
|
||||||
import six
|
|
||||||
from six.moves import configparser
|
|
||||||
if six.PY3:
|
|
||||||
from urllib import parse as urlparse
|
|
||||||
else:
|
|
||||||
import urlparse
|
|
||||||
|
|
||||||
|
|
||||||
class AmuletUtils(object):
|
|
||||||
"""Amulet utilities.
|
|
||||||
|
|
||||||
This class provides common utility functions that are used by Amulet
|
|
||||||
tests.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, log_level=logging.ERROR):
|
|
||||||
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):
|
|
||||||
"""Get a logger object that will log to stdout."""
|
|
||||||
log = logging
|
|
||||||
logger = log.getLogger(name)
|
|
||||||
fmt = log.Formatter("%(asctime)s %(funcName)s "
|
|
||||||
"%(levelname)s: %(message)s")
|
|
||||||
|
|
||||||
handler = log.StreamHandler(stream=sys.stdout)
|
|
||||||
handler.setLevel(level)
|
|
||||||
handler.setFormatter(fmt)
|
|
||||||
|
|
||||||
logger.addHandler(handler)
|
|
||||||
logger.setLevel(level)
|
|
||||||
|
|
||||||
return logger
|
|
||||||
|
|
||||||
def valid_ip(self, ip):
|
|
||||||
if re.match(r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$", ip):
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
return False
|
|
||||||
|
|
||||||
def valid_url(self, url):
|
|
||||||
p = re.compile(
|
|
||||||
r'^(?:http|ftp)s?://'
|
|
||||||
r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+(?:[A-Z]{2,6}\.?|[A-Z0-9-]{2,}\.?)|' # noqa
|
|
||||||
r'localhost|'
|
|
||||||
r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})'
|
|
||||||
r'(?::\d+)?'
|
|
||||||
r'(?:/?|[/?]\S+)$',
|
|
||||||
re.IGNORECASE)
|
|
||||||
if p.match(url):
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
return False
|
|
||||||
|
|
||||||
def get_ubuntu_release_from_sentry(self, sentry_unit):
|
|
||||||
"""Get Ubuntu release codename from sentry unit.
|
|
||||||
|
|
||||||
: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.
|
|
||||||
|
|
||||||
: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 cmd in v:
|
|
||||||
output, code = k.run(cmd)
|
|
||||||
self.log.debug('{} `{}` returned '
|
|
||||||
'{}'.format(k.info['unit_name'],
|
|
||||||
cmd, code))
|
|
||||||
if code != 0:
|
|
||||||
return "command `{}` returned {}".format(cmd, str(code))
|
|
||||||
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 in ['rabbitmq-server', 'apache2',
|
|
||||||
'memcached']):
|
|
||||||
# init is systemd (or regular sysv)
|
|
||||||
cmd = 'sudo service {} status'.format(service_name)
|
|
||||||
output, code = sentry_unit.run(cmd)
|
|
||||||
service_running = code == 0
|
|
||||||
elif self.ubuntu_releases.index(release) < systemd_switch:
|
|
||||||
# init is upstart
|
|
||||||
cmd = 'sudo status {}'.format(service_name)
|
|
||||||
output, code = sentry_unit.run(cmd)
|
|
||||||
service_running = code == 0 and "start/running" in output
|
|
||||||
|
|
||||||
self.log.debug('{} `{}` returned '
|
|
||||||
'{}'.format(sentry_unit.info['unit_name'],
|
|
||||||
cmd, code))
|
|
||||||
if not service_running:
|
|
||||||
return u"command `{}` returned {} {}".format(
|
|
||||||
cmd, output, str(code))
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _get_config(self, unit, filename):
|
|
||||||
"""Get a ConfigParser object for parsing a unit's config file."""
|
|
||||||
file_contents = unit.file_contents(filename)
|
|
||||||
|
|
||||||
# NOTE(beisner): by default, ConfigParser does not handle options
|
|
||||||
# with no value, such as the flags used in the mysql my.cnf file.
|
|
||||||
# https://bugs.python.org/issue7005
|
|
||||||
config = configparser.ConfigParser(allow_no_value=True)
|
|
||||||
config.readfp(io.StringIO(file_contents))
|
|
||||||
return config
|
|
||||||
|
|
||||||
def validate_config_data(self, sentry_unit, config_file, section,
|
|
||||||
expected):
|
|
||||||
"""Validate config file data.
|
|
||||||
|
|
||||||
Verify that the specified section of the config file contains
|
|
||||||
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)
|
|
||||||
|
|
||||||
if section != 'DEFAULT' and not config.has_section(section):
|
|
||||||
return "section [{}] does not exist".format(section)
|
|
||||||
|
|
||||||
for k in expected.keys():
|
|
||||||
if not config.has_option(section, k):
|
|
||||||
return "section [{}] is missing option {}".format(section, 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(
|
|
||||||
section, k, actual, k, expected[k])
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _validate_dict_data(self, expected, actual):
|
|
||||||
"""Validate dictionary data.
|
|
||||||
|
|
||||||
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('actual: {}'.format(repr(actual)))
|
|
||||||
self.log.debug('expected: {}'.format(repr(expected)))
|
|
||||||
|
|
||||||
for k, v in six.iteritems(expected):
|
|
||||||
if k in actual:
|
|
||||||
if (isinstance(v, six.string_types) or
|
|
||||||
isinstance(v, bool) or
|
|
||||||
isinstance(v, six.integer_types)):
|
|
||||||
# handle explicit values
|
|
||||||
if v != actual[k]:
|
|
||||||
return "{}:{}".format(k, actual[k])
|
|
||||||
# handle function pointers, such as not_null or valid_ip
|
|
||||||
elif not v(actual[k]):
|
|
||||||
return "{}:{}".format(k, actual[k])
|
|
||||||
else:
|
|
||||||
return "key '{}' does not exist".format(k)
|
|
||||||
return None
|
|
||||||
|
|
||||||
def validate_relation_data(self, sentry_unit, relation, expected):
|
|
||||||
"""Validate actual relation data based on expected relation data."""
|
|
||||||
actual = sentry_unit.relation(relation[0], relation[1])
|
|
||||||
return self._validate_dict_data(expected, actual)
|
|
||||||
|
|
||||||
def _validate_list_data(self, expected, actual):
|
|
||||||
"""Compare expected list vs actual list data."""
|
|
||||||
for e in expected:
|
|
||||||
if e not in actual:
|
|
||||||
return "expected item {} not found in actual list".format(e)
|
|
||||||
return None
|
|
||||||
|
|
||||||
def not_null(self, string):
|
|
||||||
if string is not None:
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _get_file_mtime(self, sentry_unit, filename):
|
|
||||||
"""Get last modification time of file."""
|
|
||||||
return sentry_unit.file_stat(filename)['mtime']
|
|
||||||
|
|
||||||
def _get_dir_mtime(self, sentry_unit, directory):
|
|
||||||
"""Get last modification time of directory."""
|
|
||||||
return sentry_unit.directory_stat(directory)['mtime']
|
|
||||||
|
|
||||||
def _get_proc_start_time(self, sentry_unit, service, pgrep_full=None):
|
|
||||||
"""Get start time of a process based on the last modification time
|
|
||||||
of the /proc/pid directory.
|
|
||||||
|
|
||||||
:sentry_unit: The sentry unit to check for the service on
|
|
||||||
:service: service name to look for in process table
|
|
||||||
:pgrep_full: [Deprecated] Use full command line search mode with pgrep
|
|
||||||
:returns: epoch time of service process start
|
|
||||||
:param commands: list of bash commands
|
|
||||||
:param sentry_units: list of sentry unit pointers
|
|
||||||
:returns: None if successful; Failure message otherwise
|
|
||||||
"""
|
|
||||||
if pgrep_full is not None:
|
|
||||||
# /!\ DEPRECATION WARNING (beisner):
|
|
||||||
# No longer implemented, as pidof is now used instead of pgrep.
|
|
||||||
# https://bugs.launchpad.net/charm-helpers/+bug/1474030
|
|
||||||
self.log.warn('DEPRECATION WARNING: pgrep_full bool is no '
|
|
||||||
'longer implemented re: lp 1474030.')
|
|
||||||
|
|
||||||
pid_list = self.get_process_id_list(sentry_unit, service)
|
|
||||||
pid = pid_list[0]
|
|
||||||
proc_dir = '/proc/{}'.format(pid)
|
|
||||||
self.log.debug('Pid for {} on {}: {}'.format(
|
|
||||||
service, sentry_unit.info['unit_name'], pid))
|
|
||||||
|
|
||||||
return self._get_dir_mtime(sentry_unit, proc_dir)
|
|
||||||
|
|
||||||
def service_restarted(self, sentry_unit, service, filename,
|
|
||||||
pgrep_full=None, sleep_time=20):
|
|
||||||
"""Check if service was restarted.
|
|
||||||
|
|
||||||
Compare a service's start time vs a file's last modification time
|
|
||||||
(such as a config file for that service) to determine if the service
|
|
||||||
has been restarted.
|
|
||||||
"""
|
|
||||||
# /!\ DEPRECATION WARNING (beisner):
|
|
||||||
# This method is prone to races in that no before-time is known.
|
|
||||||
# Use validate_service_config_changed instead.
|
|
||||||
|
|
||||||
# NOTE(beisner) pgrep_full is no longer implemented, as pidof is now
|
|
||||||
# used instead of pgrep. pgrep_full is still passed through to ensure
|
|
||||||
# deprecation WARNS. lp1474030
|
|
||||||
self.log.warn('DEPRECATION WARNING: use '
|
|
||||||
'validate_service_config_changed instead of '
|
|
||||||
'service_restarted due to known races.')
|
|
||||||
|
|
||||||
time.sleep(sleep_time)
|
|
||||||
if (self._get_proc_start_time(sentry_unit, service, pgrep_full) >=
|
|
||||||
self._get_file_mtime(sentry_unit, filename)):
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
return False
|
|
||||||
|
|
||||||
def service_restarted_since(self, sentry_unit, mtime, service,
|
|
||||||
pgrep_full=None, sleep_time=20,
|
|
||||||
retry_count=30, retry_sleep_time=10):
|
|
||||||
"""Check if service was been started after a given time.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
sentry_unit (sentry): The sentry unit to check for the service on
|
|
||||||
mtime (float): The epoch time to check against
|
|
||||||
service (string): service name to look for in process table
|
|
||||||
pgrep_full: [Deprecated] Use full command line search mode with pgrep
|
|
||||||
sleep_time (int): Initial sleep time (s) before looking for file
|
|
||||||
retry_sleep_time (int): Time (s) to sleep between retries
|
|
||||||
retry_count (int): If file is not found, how many times to retry
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True if service found and its start time it newer than mtime,
|
|
||||||
False if service is older than mtime or if service was
|
|
||||||
not found.
|
|
||||||
"""
|
|
||||||
# NOTE(beisner) pgrep_full is no longer implemented, as pidof is now
|
|
||||||
# used instead of pgrep. pgrep_full is still passed through to ensure
|
|
||||||
# deprecation WARNS. lp1474030
|
|
||||||
|
|
||||||
unit_name = sentry_unit.info['unit_name']
|
|
||||||
self.log.debug('Checking that %s service restarted since %s on '
|
|
||||||
'%s' % (service, mtime, unit_name))
|
|
||||||
time.sleep(sleep_time)
|
|
||||||
proc_start_time = None
|
|
||||||
tries = 0
|
|
||||||
while tries <= retry_count and not proc_start_time:
|
|
||||||
try:
|
|
||||||
proc_start_time = self._get_proc_start_time(sentry_unit,
|
|
||||||
service,
|
|
||||||
pgrep_full)
|
|
||||||
self.log.debug('Attempt {} to get {} proc start time on {} '
|
|
||||||
'OK'.format(tries, service, unit_name))
|
|
||||||
except IOError as e:
|
|
||||||
# NOTE(beisner) - race avoidance, proc may not exist yet.
|
|
||||||
# https://bugs.launchpad.net/charm-helpers/+bug/1474030
|
|
||||||
self.log.debug('Attempt {} to get {} proc start time on {} '
|
|
||||||
'failed\n{}'.format(tries, service,
|
|
||||||
unit_name, e))
|
|
||||||
time.sleep(retry_sleep_time)
|
|
||||||
tries += 1
|
|
||||||
|
|
||||||
if not proc_start_time:
|
|
||||||
self.log.warn('No proc start time found, assuming service did '
|
|
||||||
'not start')
|
|
||||||
return False
|
|
||||||
if proc_start_time >= mtime:
|
|
||||||
self.log.debug('Proc start time is newer than provided mtime'
|
|
||||||
'(%s >= %s) on %s (OK)' % (proc_start_time,
|
|
||||||
mtime, unit_name))
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
self.log.warn('Proc start time (%s) is older than provided mtime '
|
|
||||||
'(%s) on %s, service did not '
|
|
||||||
'restart' % (proc_start_time, mtime, unit_name))
|
|
||||||
return False
|
|
||||||
|
|
||||||
def config_updated_since(self, sentry_unit, filename, mtime,
|
|
||||||
sleep_time=20, retry_count=30,
|
|
||||||
retry_sleep_time=10):
|
|
||||||
"""Check if file was modified after a given time.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
sentry_unit (sentry): The sentry unit to check the file mtime on
|
|
||||||
filename (string): The file to check mtime of
|
|
||||||
mtime (float): The epoch time to check against
|
|
||||||
sleep_time (int): Initial sleep time (s) before looking for file
|
|
||||||
retry_sleep_time (int): Time (s) to sleep between retries
|
|
||||||
retry_count (int): If file is not found, how many times to retry
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
bool: True if file was modified more recently than mtime, False if
|
|
||||||
file was modified before mtime, or if file not found.
|
|
||||||
"""
|
|
||||||
unit_name = sentry_unit.info['unit_name']
|
|
||||||
self.log.debug('Checking that %s updated since %s on '
|
|
||||||
'%s' % (filename, mtime, unit_name))
|
|
||||||
time.sleep(sleep_time)
|
|
||||||
file_mtime = None
|
|
||||||
tries = 0
|
|
||||||
while tries <= retry_count and not file_mtime:
|
|
||||||
try:
|
|
||||||
file_mtime = self._get_file_mtime(sentry_unit, filename)
|
|
||||||
self.log.debug('Attempt {} to get {} file mtime on {} '
|
|
||||||
'OK'.format(tries, filename, unit_name))
|
|
||||||
except IOError as e:
|
|
||||||
# NOTE(beisner) - race avoidance, file may not exist yet.
|
|
||||||
# https://bugs.launchpad.net/charm-helpers/+bug/1474030
|
|
||||||
self.log.debug('Attempt {} to get {} file mtime on {} '
|
|
||||||
'failed\n{}'.format(tries, filename,
|
|
||||||
unit_name, e))
|
|
||||||
time.sleep(retry_sleep_time)
|
|
||||||
tries += 1
|
|
||||||
|
|
||||||
if not file_mtime:
|
|
||||||
self.log.warn('Could not determine file mtime, assuming '
|
|
||||||
'file does not exist')
|
|
||||||
return False
|
|
||||||
|
|
||||||
if file_mtime >= mtime:
|
|
||||||
self.log.debug('File mtime is newer than provided mtime '
|
|
||||||
'(%s >= %s) on %s (OK)' % (file_mtime,
|
|
||||||
mtime, unit_name))
|
|
||||||
return True
|
|
||||||
else:
|
|
||||||
self.log.warn('File mtime is older than provided mtime'
|
|
||||||
'(%s < on %s) on %s' % (file_mtime,
|
|
||||||
mtime, unit_name))
|
|
||||||
return False
|
|
||||||
|
|
||||||
def validate_service_config_changed(self, sentry_unit, mtime, service,
|
|
||||||
filename, pgrep_full=None,
|
|
||||||
sleep_time=20, retry_count=30,
|
|
||||||
retry_sleep_time=10):
|
|
||||||
"""Check service and file were updated after mtime
|
|
||||||
|
|
||||||
Args:
|
|
||||||
sentry_unit (sentry): The sentry unit to check for the service on
|
|
||||||
mtime (float): The epoch time to check against
|
|
||||||
service (string): service name to look for in process table
|
|
||||||
filename (string): The file to check mtime of
|
|
||||||
pgrep_full: [Deprecated] Use full command line search mode with pgrep
|
|
||||||
sleep_time (int): Initial sleep in seconds to pass to test helpers
|
|
||||||
retry_count (int): If service is not found, how many times to retry
|
|
||||||
retry_sleep_time (int): Time in seconds to wait between retries
|
|
||||||
|
|
||||||
Typical Usage:
|
|
||||||
u = OpenStackAmuletUtils(ERROR)
|
|
||||||
...
|
|
||||||
mtime = u.get_sentry_time(self.cinder_sentry)
|
|
||||||
self.d.configure('cinder', {'verbose': 'True', 'debug': 'True'})
|
|
||||||
if not u.validate_service_config_changed(self.cinder_sentry,
|
|
||||||
mtime,
|
|
||||||
'cinder-api',
|
|
||||||
'/etc/cinder/cinder.conf')
|
|
||||||
amulet.raise_status(amulet.FAIL, msg='update failed')
|
|
||||||
Returns:
|
|
||||||
bool: True if both service and file where updated/restarted after
|
|
||||||
mtime, False if service is older than mtime or if service was
|
|
||||||
not found or if filename was modified before mtime.
|
|
||||||
"""
|
|
||||||
|
|
||||||
# NOTE(beisner) pgrep_full is no longer implemented, as pidof is now
|
|
||||||
# used instead of pgrep. pgrep_full is still passed through to ensure
|
|
||||||
# deprecation WARNS. lp1474030
|
|
||||||
|
|
||||||
service_restart = self.service_restarted_since(
|
|
||||||
sentry_unit, mtime,
|
|
||||||
service,
|
|
||||||
pgrep_full=pgrep_full,
|
|
||||||
sleep_time=sleep_time,
|
|
||||||
retry_count=retry_count,
|
|
||||||
retry_sleep_time=retry_sleep_time)
|
|
||||||
|
|
||||||
config_update = self.config_updated_since(
|
|
||||||
sentry_unit,
|
|
||||||
filename,
|
|
||||||
mtime,
|
|
||||||
sleep_time=sleep_time,
|
|
||||||
retry_count=retry_count,
|
|
||||||
retry_sleep_time=retry_sleep_time)
|
|
||||||
|
|
||||||
return service_restart and config_update
|
|
||||||
|
|
||||||
def get_sentry_time(self, sentry_unit):
|
|
||||||
"""Return current epoch time on a sentry"""
|
|
||||||
cmd = "date +'%s'"
|
|
||||||
return float(sentry_unit.run(cmd)[0])
|
|
||||||
|
|
||||||
def relation_error(self, name, data):
|
|
||||||
return 'unexpected relation data in {} - {}'.format(name, data)
|
|
||||||
|
|
||||||
def endpoint_error(self, 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
|
|
||||||
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,
|
|
||||||
expect_success=True):
|
|
||||||
"""Get a list of process ID(s) from a single sentry juju unit
|
|
||||||
for a single process name.
|
|
||||||
|
|
||||||
:param sentry_unit: Amulet sentry instance (juju unit)
|
|
||||||
:param process_name: Process name
|
|
||||||
:param expect_success: If False, expect the PID to be missing,
|
|
||||||
raise if it is present.
|
|
||||||
:returns: List of process IDs
|
|
||||||
"""
|
|
||||||
cmd = 'pidof -x "{}"'.format(process_name)
|
|
||||||
if not expect_success:
|
|
||||||
cmd += " || exit 0 && exit 1"
|
|
||||||
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, expect_success=True):
|
|
||||||
"""Construct a dict containing unit sentries, process names, and
|
|
||||||
process IDs.
|
|
||||||
|
|
||||||
:param unit_processes: A dictionary of Amulet sentry instance
|
|
||||||
to list of process names.
|
|
||||||
:param expect_success: if False expect the processes to not be
|
|
||||||
running, raise if they are.
|
|
||||||
:returns: Dictionary of Amulet sentry instance to dictionary
|
|
||||||
of process names to PIDs.
|
|
||||||
"""
|
|
||||||
pid_dict = {}
|
|
||||||
for sentry_unit, process_list in six.iteritems(unit_processes):
|
|
||||||
pid_dict[sentry_unit] = {}
|
|
||||||
for process in process_list:
|
|
||||||
pids = self.get_process_id_list(
|
|
||||||
sentry_unit, process, expect_success=expect_success)
|
|
||||||
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 six.iteritems(expected):
|
|
||||||
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), (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)
|
|
||||||
fail_msg = ('PID count mismatch. {} ({}) expected, actual: '
|
|
||||||
'{}, {} ({})'.format(e_sentry_name, e_proc_name,
|
|
||||||
e_pids, a_pids_length,
|
|
||||||
a_pids))
|
|
||||||
|
|
||||||
# If expected is a list, ensure at least one PID quantity match
|
|
||||||
if isinstance(e_pids, list) and \
|
|
||||||
a_pids_length not in e_pids:
|
|
||||||
return fail_msg
|
|
||||||
# If expected is not bool and not list,
|
|
||||||
# ensure PID quantities match
|
|
||||||
elif not isinstance(e_pids, bool) and \
|
|
||||||
not isinstance(e_pids, list) and \
|
|
||||||
a_pids_length != e_pids:
|
|
||||||
return fail_msg
|
|
||||||
# If expected is bool True, ensure 1 or more PIDs exist
|
|
||||||
elif isinstance(e_pids, bool) and \
|
|
||||||
e_pids is True and a_pids_length < 1:
|
|
||||||
return fail_msg
|
|
||||||
# If expected is bool False, ensure 0 PIDs exist
|
|
||||||
elif isinstance(e_pids, bool) and \
|
|
||||||
e_pids is False and a_pids_length != 0:
|
|
||||||
return fail_msg
|
|
||||||
else:
|
|
||||||
self.log.debug('PID check OK: {} {} {}: '
|
|
||||||
'{}'.format(e_sentry_name, e_proc_name,
|
|
||||||
e_pids, 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
|
|
||||||
|
|
||||||
def validate_sectionless_conf(self, file_contents, expected):
|
|
||||||
"""A crude conf parser. Useful to inspect configuration files which
|
|
||||||
do not have section headers (as would be necessary in order to use
|
|
||||||
the configparser). Such as openstack-dashboard or rabbitmq confs."""
|
|
||||||
for line in file_contents.split('\n'):
|
|
||||||
if '=' in line:
|
|
||||||
args = line.split('=')
|
|
||||||
if len(args) <= 1:
|
|
||||||
continue
|
|
||||||
key = args[0].strip()
|
|
||||||
value = args[1].strip()
|
|
||||||
if key in expected.keys():
|
|
||||||
if expected[key] != value:
|
|
||||||
msg = ('Config mismatch. Expected, actual: {}, '
|
|
||||||
'{}'.format(expected[key], value))
|
|
||||||
amulet.raise_status(amulet.FAIL, msg=msg)
|
|
||||||
|
|
||||||
def get_unit_hostnames(self, units):
|
|
||||||
"""Return a dict of juju unit names to hostnames."""
|
|
||||||
host_names = {}
|
|
||||||
for unit in units:
|
|
||||||
host_names[unit.info['unit_name']] = \
|
|
||||||
str(unit.file_contents('/etc/hostname').strip())
|
|
||||||
self.log.debug('Unit host names: {}'.format(host_names))
|
|
||||||
return host_names
|
|
||||||
|
|
||||||
def run_cmd_unit(self, sentry_unit, cmd):
|
|
||||||
"""Run a command on a unit, return the output and exit code."""
|
|
||||||
output, code = sentry_unit.run(cmd)
|
|
||||||
if code == 0:
|
|
||||||
self.log.debug('{} `{}` command returned {} '
|
|
||||||
'(OK)'.format(sentry_unit.info['unit_name'],
|
|
||||||
cmd, code))
|
|
||||||
else:
|
|
||||||
msg = ('{} `{}` command returned {} '
|
|
||||||
'{}'.format(sentry_unit.info['unit_name'],
|
|
||||||
cmd, code, output))
|
|
||||||
amulet.raise_status(amulet.FAIL, msg=msg)
|
|
||||||
return str(output), code
|
|
||||||
|
|
||||||
def file_exists_on_unit(self, sentry_unit, file_name):
|
|
||||||
"""Check if a file exists on a unit."""
|
|
||||||
try:
|
|
||||||
sentry_unit.file_stat(file_name)
|
|
||||||
return True
|
|
||||||
except IOError:
|
|
||||||
return False
|
|
||||||
except Exception as e:
|
|
||||||
msg = 'Error checking file {}: {}'.format(file_name, e)
|
|
||||||
amulet.raise_status(amulet.FAIL, msg=msg)
|
|
||||||
|
|
||||||
def file_contents_safe(self, sentry_unit, file_name,
|
|
||||||
max_wait=60, fatal=False):
|
|
||||||
"""Get file contents from a sentry unit. Wrap amulet file_contents
|
|
||||||
with retry logic to address races where a file checks as existing,
|
|
||||||
but no longer exists by the time file_contents is called.
|
|
||||||
Return None if file not found. Optionally raise if fatal is True."""
|
|
||||||
unit_name = sentry_unit.info['unit_name']
|
|
||||||
file_contents = False
|
|
||||||
tries = 0
|
|
||||||
while not file_contents and tries < (max_wait / 4):
|
|
||||||
try:
|
|
||||||
file_contents = sentry_unit.file_contents(file_name)
|
|
||||||
except IOError:
|
|
||||||
self.log.debug('Attempt {} to open file {} from {} '
|
|
||||||
'failed'.format(tries, file_name,
|
|
||||||
unit_name))
|
|
||||||
time.sleep(4)
|
|
||||||
tries += 1
|
|
||||||
|
|
||||||
if file_contents:
|
|
||||||
return file_contents
|
|
||||||
elif not fatal:
|
|
||||||
return None
|
|
||||||
elif fatal:
|
|
||||||
msg = 'Failed to get file contents from unit.'
|
|
||||||
amulet.raise_status(amulet.FAIL, msg)
|
|
||||||
|
|
||||||
def port_knock_tcp(self, host="localhost", port=22, timeout=15):
|
|
||||||
"""Open a TCP socket to check for a listening sevice on a host.
|
|
||||||
|
|
||||||
:param host: host name or IP address, default to localhost
|
|
||||||
:param port: TCP port number, default to 22
|
|
||||||
:param timeout: Connect timeout, default to 15 seconds
|
|
||||||
:returns: True if successful, False if connect failed
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Resolve host name if possible
|
|
||||||
try:
|
|
||||||
connect_host = socket.gethostbyname(host)
|
|
||||||
host_human = "{} ({})".format(connect_host, host)
|
|
||||||
except socket.error as e:
|
|
||||||
self.log.warn('Unable to resolve address: '
|
|
||||||
'{} ({}) Trying anyway!'.format(host, e))
|
|
||||||
connect_host = host
|
|
||||||
host_human = connect_host
|
|
||||||
|
|
||||||
# Attempt socket connection
|
|
||||||
try:
|
|
||||||
knock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
||||||
knock.settimeout(timeout)
|
|
||||||
knock.connect((connect_host, port))
|
|
||||||
knock.close()
|
|
||||||
self.log.debug('Socket connect OK for host '
|
|
||||||
'{} on port {}.'.format(host_human, port))
|
|
||||||
return True
|
|
||||||
except socket.error as e:
|
|
||||||
self.log.debug('Socket connect FAIL for'
|
|
||||||
' {} port {} ({})'.format(host_human, port, e))
|
|
||||||
return False
|
|
||||||
|
|
||||||
def port_knock_units(self, sentry_units, port=22,
|
|
||||||
timeout=15, expect_success=True):
|
|
||||||
"""Open a TCP socket to check for a listening sevice on each
|
|
||||||
listed juju unit.
|
|
||||||
|
|
||||||
:param sentry_units: list of sentry unit pointers
|
|
||||||
:param port: TCP port number, default to 22
|
|
||||||
:param timeout: Connect timeout, default to 15 seconds
|
|
||||||
:expect_success: True by default, set False to invert logic
|
|
||||||
:returns: None if successful, Failure message otherwise
|
|
||||||
"""
|
|
||||||
for unit in sentry_units:
|
|
||||||
host = unit.info['public-address']
|
|
||||||
connected = self.port_knock_tcp(host, port, timeout)
|
|
||||||
if not connected and expect_success:
|
|
||||||
return 'Socket connect failed.'
|
|
||||||
elif connected and not expect_success:
|
|
||||||
return 'Socket connected unexpectedly.'
|
|
||||||
|
|
||||||
def get_uuid_epoch_stamp(self):
|
|
||||||
"""Returns a stamp string based on uuid4 and epoch time. Useful in
|
|
||||||
generating test messages which need to be unique-ish."""
|
|
||||||
return '[{}-{}]'.format(uuid.uuid4(), time.time())
|
|
||||||
|
|
||||||
# amulet juju action helpers:
|
|
||||||
def run_action(self, unit_sentry, action,
|
|
||||||
_check_output=subprocess.check_output,
|
|
||||||
params=None):
|
|
||||||
"""Translate to amulet's built in run_action(). Deprecated.
|
|
||||||
|
|
||||||
Run the named action on a given unit sentry.
|
|
||||||
|
|
||||||
params a dict of parameters to use
|
|
||||||
_check_output parameter is no longer used
|
|
||||||
|
|
||||||
@return action_id.
|
|
||||||
"""
|
|
||||||
self.log.warn('charmhelpers.contrib.amulet.utils.run_action has been '
|
|
||||||
'deprecated for amulet.run_action')
|
|
||||||
return unit_sentry.run_action(action, action_args=params)
|
|
||||||
|
|
||||||
def wait_on_action(self, action_id, _check_output=subprocess.check_output):
|
|
||||||
"""Wait for a given action, returning if it completed or not.
|
|
||||||
|
|
||||||
action_id a string action uuid
|
|
||||||
_check_output parameter is no longer used
|
|
||||||
"""
|
|
||||||
data = amulet.actions.get_action_output(action_id, full_output=True)
|
|
||||||
return data.get(u"status") == "completed"
|
|
||||||
|
|
||||||
def status_get(self, unit):
|
|
||||||
"""Return the current service status of this unit."""
|
|
||||||
raw_status, return_code = unit.run(
|
|
||||||
"status-get --format=json --include-data")
|
|
||||||
if return_code != 0:
|
|
||||||
return ("unknown", "")
|
|
||||||
status = json.loads(raw_status)
|
|
||||||
return (status["status"], status["message"])
|
|
@ -1,13 +0,0 @@
|
|||||||
# Copyright 2014-2015 Canonical Limited.
|
|
||||||
#
|
|
||||||
# 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.
|
|
@ -1,13 +0,0 @@
|
|||||||
# Copyright 2014-2015 Canonical Limited.
|
|
||||||
#
|
|
||||||
# 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.
|
|
@ -1,357 +0,0 @@
|
|||||||
# Copyright 2014-2015 Canonical Limited.
|
|
||||||
#
|
|
||||||
# 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.
|
|
||||||
|
|
||||||
import logging
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
import sys
|
|
||||||
import six
|
|
||||||
from collections import OrderedDict
|
|
||||||
from charmhelpers.contrib.amulet.deployment import (
|
|
||||||
AmuletDeployment
|
|
||||||
)
|
|
||||||
from charmhelpers.contrib.openstack.amulet.utils import (
|
|
||||||
OPENSTACK_RELEASES_PAIRS
|
|
||||||
)
|
|
||||||
|
|
||||||
DEBUG = logging.DEBUG
|
|
||||||
ERROR = logging.ERROR
|
|
||||||
|
|
||||||
|
|
||||||
class OpenStackAmuletDeployment(AmuletDeployment):
|
|
||||||
"""OpenStack amulet deployment.
|
|
||||||
|
|
||||||
This class inherits from AmuletDeployment and has additional support
|
|
||||||
that is specifically for use by OpenStack charms.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, series=None, openstack=None, source=None,
|
|
||||||
stable=True, log_level=DEBUG):
|
|
||||||
"""Initialize the deployment environment."""
|
|
||||||
super(OpenStackAmuletDeployment, self).__init__(series)
|
|
||||||
self.log = self.get_logger(level=log_level)
|
|
||||||
self.log.info('OpenStackAmuletDeployment: init')
|
|
||||||
self.openstack = openstack
|
|
||||||
self.source = source
|
|
||||||
self.stable = stable
|
|
||||||
|
|
||||||
def get_logger(self, name="deployment-logger", level=logging.DEBUG):
|
|
||||||
"""Get a logger object that will log to stdout."""
|
|
||||||
log = logging
|
|
||||||
logger = log.getLogger(name)
|
|
||||||
fmt = log.Formatter("%(asctime)s %(funcName)s "
|
|
||||||
"%(levelname)s: %(message)s")
|
|
||||||
|
|
||||||
handler = log.StreamHandler(stream=sys.stdout)
|
|
||||||
handler.setLevel(level)
|
|
||||||
handler.setFormatter(fmt)
|
|
||||||
|
|
||||||
logger.addHandler(handler)
|
|
||||||
logger.setLevel(level)
|
|
||||||
|
|
||||||
return logger
|
|
||||||
|
|
||||||
def _determine_branch_locations(self, other_services):
|
|
||||||
"""Determine the branch locations for the other services.
|
|
||||||
|
|
||||||
Determine if the local branch being tested is derived from its
|
|
||||||
stable or next (dev) branch, and based on this, use the corresonding
|
|
||||||
stable or next branches for the other_services."""
|
|
||||||
|
|
||||||
self.log.info('OpenStackAmuletDeployment: determine branch locations')
|
|
||||||
|
|
||||||
# Charms outside the ~openstack-charmers
|
|
||||||
base_charms = {
|
|
||||||
'mysql': ['trusty'],
|
|
||||||
'mongodb': ['trusty'],
|
|
||||||
'nrpe': ['trusty', 'xenial'],
|
|
||||||
}
|
|
||||||
|
|
||||||
for svc in other_services:
|
|
||||||
# If a location has been explicitly set, use it
|
|
||||||
if svc.get('location'):
|
|
||||||
continue
|
|
||||||
if svc['name'] in base_charms:
|
|
||||||
# NOTE: not all charms have support for all series we
|
|
||||||
# want/need to test against, so fix to most recent
|
|
||||||
# that each base charm supports
|
|
||||||
target_series = self.series
|
|
||||||
if self.series not in base_charms[svc['name']]:
|
|
||||||
target_series = base_charms[svc['name']][-1]
|
|
||||||
svc['location'] = 'cs:{}/{}'.format(target_series,
|
|
||||||
svc['name'])
|
|
||||||
elif self.stable:
|
|
||||||
svc['location'] = 'cs:{}/{}'.format(self.series,
|
|
||||||
svc['name'])
|
|
||||||
else:
|
|
||||||
svc['location'] = 'cs:~openstack-charmers-next/{}/{}'.format(
|
|
||||||
self.series,
|
|
||||||
svc['name']
|
|
||||||
)
|
|
||||||
|
|
||||||
return other_services
|
|
||||||
|
|
||||||
def _add_services(self, this_service, other_services, use_source=None,
|
|
||||||
no_origin=None):
|
|
||||||
"""Add services to the deployment and optionally set
|
|
||||||
openstack-origin/source.
|
|
||||||
|
|
||||||
:param this_service dict: Service dictionary describing the service
|
|
||||||
whose amulet tests are being run
|
|
||||||
:param other_services dict: List of service dictionaries describing
|
|
||||||
the services needed to support the target
|
|
||||||
service
|
|
||||||
:param use_source list: List of services which use the 'source' config
|
|
||||||
option rather than 'openstack-origin'
|
|
||||||
:param no_origin list: List of services which do not support setting
|
|
||||||
the Cloud Archive.
|
|
||||||
Service Dict:
|
|
||||||
{
|
|
||||||
'name': str charm-name,
|
|
||||||
'units': int number of units,
|
|
||||||
'constraints': dict of juju constraints,
|
|
||||||
'location': str location of charm,
|
|
||||||
}
|
|
||||||
eg
|
|
||||||
this_service = {
|
|
||||||
'name': 'openvswitch-odl',
|
|
||||||
'constraints': {'mem': '8G'},
|
|
||||||
}
|
|
||||||
other_services = [
|
|
||||||
{
|
|
||||||
'name': 'nova-compute',
|
|
||||||
'units': 2,
|
|
||||||
'constraints': {'mem': '4G'},
|
|
||||||
'location': cs:~bob/xenial/nova-compute
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'name': 'mysql',
|
|
||||||
'constraints': {'mem': '2G'},
|
|
||||||
},
|
|
||||||
{'neutron-api-odl'}]
|
|
||||||
use_source = ['mysql']
|
|
||||||
no_origin = ['neutron-api-odl']
|
|
||||||
"""
|
|
||||||
self.log.info('OpenStackAmuletDeployment: adding services')
|
|
||||||
|
|
||||||
other_services = self._determine_branch_locations(other_services)
|
|
||||||
|
|
||||||
super(OpenStackAmuletDeployment, self)._add_services(this_service,
|
|
||||||
other_services)
|
|
||||||
|
|
||||||
services = other_services
|
|
||||||
services.append(this_service)
|
|
||||||
|
|
||||||
use_source = use_source or []
|
|
||||||
no_origin = no_origin or []
|
|
||||||
|
|
||||||
# Charms which should use the source config option
|
|
||||||
use_source = list(set(
|
|
||||||
use_source + ['mysql', 'mongodb', 'rabbitmq-server', 'ceph',
|
|
||||||
'ceph-osd', 'ceph-radosgw', 'ceph-mon',
|
|
||||||
'ceph-proxy', 'percona-cluster', 'lxd']))
|
|
||||||
|
|
||||||
# Charms which can not use openstack-origin, ie. many subordinates
|
|
||||||
no_origin = list(set(
|
|
||||||
no_origin + ['cinder-ceph', 'hacluster', 'neutron-openvswitch',
|
|
||||||
'nrpe', 'openvswitch-odl', 'neutron-api-odl',
|
|
||||||
'odl-controller', 'cinder-backup', 'nexentaedge-data',
|
|
||||||
'nexentaedge-iscsi-gw', 'nexentaedge-swift-gw',
|
|
||||||
'cinder-nexentaedge', 'nexentaedge-mgmt']))
|
|
||||||
|
|
||||||
if self.openstack:
|
|
||||||
for svc in services:
|
|
||||||
if svc['name'] not in use_source + no_origin:
|
|
||||||
config = {'openstack-origin': self.openstack}
|
|
||||||
self.d.configure(svc['name'], config)
|
|
||||||
|
|
||||||
if self.source:
|
|
||||||
for svc in services:
|
|
||||||
if svc['name'] in use_source and svc['name'] not in no_origin:
|
|
||||||
config = {'source': self.source}
|
|
||||||
self.d.configure(svc['name'], config)
|
|
||||||
|
|
||||||
def _configure_services(self, configs):
|
|
||||||
"""Configure all of the services."""
|
|
||||||
self.log.info('OpenStackAmuletDeployment: configure services')
|
|
||||||
for service, config in six.iteritems(configs):
|
|
||||||
self.d.configure(service, config)
|
|
||||||
|
|
||||||
def _auto_wait_for_status(self, message=None, exclude_services=None,
|
|
||||||
include_only=None, timeout=None):
|
|
||||||
"""Wait for all units to have a specific extended status, except
|
|
||||||
for any defined as excluded. Unless specified via message, any
|
|
||||||
status containing any case of 'ready' will be considered a match.
|
|
||||||
|
|
||||||
Examples of message usage:
|
|
||||||
|
|
||||||
Wait for all unit status to CONTAIN any case of 'ready' or 'ok':
|
|
||||||
message = re.compile('.*ready.*|.*ok.*', re.IGNORECASE)
|
|
||||||
|
|
||||||
Wait for all units to reach this status (exact match):
|
|
||||||
message = re.compile('^Unit is ready and clustered$')
|
|
||||||
|
|
||||||
Wait for all units to reach any one of these (exact match):
|
|
||||||
message = re.compile('Unit is ready|OK|Ready')
|
|
||||||
|
|
||||||
Wait for at least one unit to reach this status (exact match):
|
|
||||||
message = {'ready'}
|
|
||||||
|
|
||||||
See Amulet's sentry.wait_for_messages() for message usage detail.
|
|
||||||
https://github.com/juju/amulet/blob/master/amulet/sentry.py
|
|
||||||
|
|
||||||
:param message: Expected status match
|
|
||||||
:param exclude_services: List of juju service names to ignore,
|
|
||||||
not to be used in conjuction with include_only.
|
|
||||||
:param include_only: List of juju service names to exclusively check,
|
|
||||||
not to be used in conjuction with exclude_services.
|
|
||||||
:param timeout: Maximum time in seconds to wait for status match
|
|
||||||
:returns: None. Raises if timeout is hit.
|
|
||||||
"""
|
|
||||||
if not timeout:
|
|
||||||
timeout = int(os.environ.get('AMULET_SETUP_TIMEOUT', 1800))
|
|
||||||
self.log.info('Waiting for extended status on units for {}s...'
|
|
||||||
''.format(timeout))
|
|
||||||
|
|
||||||
all_services = self.d.services.keys()
|
|
||||||
|
|
||||||
if exclude_services and include_only:
|
|
||||||
raise ValueError('exclude_services can not be used '
|
|
||||||
'with include_only')
|
|
||||||
|
|
||||||
if message:
|
|
||||||
if isinstance(message, re._pattern_type):
|
|
||||||
match = message.pattern
|
|
||||||
else:
|
|
||||||
match = message
|
|
||||||
|
|
||||||
self.log.debug('Custom extended status wait match: '
|
|
||||||
'{}'.format(match))
|
|
||||||
else:
|
|
||||||
self.log.debug('Default extended status wait match: contains '
|
|
||||||
'READY (case-insensitive)')
|
|
||||||
message = re.compile('.*ready.*', re.IGNORECASE)
|
|
||||||
|
|
||||||
if exclude_services:
|
|
||||||
self.log.debug('Excluding services from extended status match: '
|
|
||||||
'{}'.format(exclude_services))
|
|
||||||
else:
|
|
||||||
exclude_services = []
|
|
||||||
|
|
||||||
if include_only:
|
|
||||||
services = include_only
|
|
||||||
else:
|
|
||||||
services = list(set(all_services) - set(exclude_services))
|
|
||||||
|
|
||||||
self.log.debug('Waiting up to {}s for extended status on services: '
|
|
||||||
'{}'.format(timeout, services))
|
|
||||||
service_messages = {service: message for service in services}
|
|
||||||
|
|
||||||
# Check for idleness
|
|
||||||
self.d.sentry.wait(timeout=timeout)
|
|
||||||
# Check for error states and bail early
|
|
||||||
self.d.sentry.wait_for_status(self.d.juju_env, services, timeout=timeout)
|
|
||||||
# Check for ready messages
|
|
||||||
self.d.sentry.wait_for_messages(service_messages, timeout=timeout)
|
|
||||||
|
|
||||||
self.log.info('OK')
|
|
||||||
|
|
||||||
def _get_openstack_release(self):
|
|
||||||
"""Get openstack release.
|
|
||||||
|
|
||||||
Return an integer representing the enum value of the openstack
|
|
||||||
release.
|
|
||||||
"""
|
|
||||||
# Must be ordered by OpenStack release (not by Ubuntu release):
|
|
||||||
for i, os_pair in enumerate(OPENSTACK_RELEASES_PAIRS):
|
|
||||||
setattr(self, os_pair, i)
|
|
||||||
|
|
||||||
releases = {
|
|
||||||
('trusty', None): self.trusty_icehouse,
|
|
||||||
('trusty', 'cloud:trusty-kilo'): self.trusty_kilo,
|
|
||||||
('trusty', 'cloud:trusty-liberty'): self.trusty_liberty,
|
|
||||||
('trusty', 'cloud:trusty-mitaka'): self.trusty_mitaka,
|
|
||||||
('xenial', None): self.xenial_mitaka,
|
|
||||||
('xenial', 'cloud:xenial-newton'): self.xenial_newton,
|
|
||||||
('xenial', 'cloud:xenial-ocata'): self.xenial_ocata,
|
|
||||||
('xenial', 'cloud:xenial-pike'): self.xenial_pike,
|
|
||||||
('xenial', 'cloud:xenial-queens'): self.xenial_queens,
|
|
||||||
('yakkety', None): self.yakkety_newton,
|
|
||||||
('zesty', None): self.zesty_ocata,
|
|
||||||
('artful', None): self.artful_pike,
|
|
||||||
('bionic', None): self.bionic_queens,
|
|
||||||
('bionic', 'cloud:bionic-rocky'): self.bionic_rocky,
|
|
||||||
('cosmic', None): self.cosmic_rocky,
|
|
||||||
}
|
|
||||||
return releases[(self.series, self.openstack)]
|
|
||||||
|
|
||||||
def _get_openstack_release_string(self):
|
|
||||||
"""Get openstack release string.
|
|
||||||
|
|
||||||
Return a string representing the openstack release.
|
|
||||||
"""
|
|
||||||
releases = OrderedDict([
|
|
||||||
('trusty', 'icehouse'),
|
|
||||||
('xenial', 'mitaka'),
|
|
||||||
('yakkety', 'newton'),
|
|
||||||
('zesty', 'ocata'),
|
|
||||||
('artful', 'pike'),
|
|
||||||
('bionic', 'queens'),
|
|
||||||
('cosmic', 'rocky'),
|
|
||||||
])
|
|
||||||
if self.openstack:
|
|
||||||
os_origin = self.openstack.split(':')[1]
|
|
||||||
return os_origin.split('%s-' % self.series)[1].split('/')[0]
|
|
||||||
else:
|
|
||||||
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_icehouse:
|
|
||||||
# Icehouse
|
|
||||||
pools = [
|
|
||||||
'data',
|
|
||||||
'metadata',
|
|
||||||
'rbd',
|
|
||||||
'cinder-ceph',
|
|
||||||
'glance'
|
|
||||||
]
|
|
||||||
elif (self.trusty_kilo <= self._get_openstack_release() <=
|
|
||||||
self.zesty_ocata):
|
|
||||||
# Kilo through Ocata
|
|
||||||
pools = [
|
|
||||||
'rbd',
|
|
||||||
'cinder-ceph',
|
|
||||||
'glance'
|
|
||||||
]
|
|
||||||
else:
|
|
||||||
# Pike and later
|
|
||||||
pools = [
|
|
||||||
'cinder-ceph',
|
|
||||||
'glance'
|
|
||||||
]
|
|
||||||
|
|
||||||
if radosgw:
|
|
||||||
pools.extend([
|
|
||||||
'.rgw.root',
|
|
||||||
'.rgw.control',
|
|
||||||
'.rgw',
|
|
||||||
'.rgw.gc',
|
|
||||||
'.users.uid'
|
|
||||||
])
|
|
||||||
|
|
||||||
return pools
|
|
File diff suppressed because it is too large
Load Diff
@ -1,13 +0,0 @@
|
|||||||
# Copyright 2014-2015 Canonical Limited.
|
|
||||||
#
|
|
||||||
# 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.
|
|
@ -1,55 +0,0 @@
|
|||||||
# Copyright 2014-2015 Canonical Limited.
|
|
||||||
#
|
|
||||||
# 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.
|
|
||||||
|
|
||||||
#
|
|
||||||
# Copyright 2014 Canonical Ltd.
|
|
||||||
#
|
|
||||||
# Authors:
|
|
||||||
# Edward Hope-Morley <opentastic@gmail.com>
|
|
||||||
#
|
|
||||||
|
|
||||||
import time
|
|
||||||
|
|
||||||
from charmhelpers.core.hookenv import (
|
|
||||||
log,
|
|
||||||
INFO,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def retry_on_exception(num_retries, base_delay=0, exc_type=Exception):
|
|
||||||
"""If the decorated function raises exception exc_type, allow num_retries
|
|
||||||
retry attempts before raise the exception.
|
|
||||||
"""
|
|
||||||
def _retry_on_exception_inner_1(f):
|
|
||||||
def _retry_on_exception_inner_2(*args, **kwargs):
|
|
||||||
retries = num_retries
|
|
||||||
multiplier = 1
|
|
||||||
while True:
|
|
||||||
try:
|
|
||||||
return f(*args, **kwargs)
|
|
||||||
except exc_type:
|
|
||||||
if not retries:
|
|
||||||
raise
|
|
||||||
|
|
||||||
delay = base_delay * multiplier
|
|
||||||
multiplier += 1
|
|
||||||
log("Retrying '%s' %d more times (delay=%s)" %
|
|
||||||
(f.__name__, retries, delay), level=INFO)
|
|
||||||
retries -= 1
|
|
||||||
if delay:
|
|
||||||
time.sleep(delay)
|
|
||||||
|
|
||||||
return _retry_on_exception_inner_2
|
|
||||||
|
|
||||||
return _retry_on_exception_inner_1
|
|
@ -1,43 +0,0 @@
|
|||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
# Copyright 2014-2015 Canonical Limited.
|
|
||||||
#
|
|
||||||
# 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.
|
|
||||||
|
|
||||||
__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)])
|
|
@ -1,132 +0,0 @@
|
|||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
# Copyright 2014-2015 Canonical Limited.
|
|
||||||
#
|
|
||||||
# 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.
|
|
||||||
|
|
||||||
import io
|
|
||||||
import os
|
|
||||||
|
|
||||||
__author__ = 'Jorge Niedbalski R. <jorge.niedbalski@canonical.com>'
|
|
||||||
|
|
||||||
|
|
||||||
class Fstab(io.FileIO):
|
|
||||||
"""This class extends file in order to implement a file reader/writer
|
|
||||||
for file `/etc/fstab`
|
|
||||||
"""
|
|
||||||
|
|
||||||
class Entry(object):
|
|
||||||
"""Entry class represents a non-comment line on the `/etc/fstab` file
|
|
||||||
"""
|
|
||||||
def __init__(self, device, mountpoint, filesystem,
|
|
||||||
options, d=0, p=0):
|
|
||||||
self.device = device
|
|
||||||
self.mountpoint = mountpoint
|
|
||||||
self.filesystem = filesystem
|
|
||||||
|
|
||||||
if not options:
|
|
||||||
options = "defaults"
|
|
||||||
|
|
||||||
self.options = options
|
|
||||||
self.d = int(d)
|
|
||||||
self.p = int(p)
|
|
||||||
|
|
||||||
def __eq__(self, o):
|
|
||||||
return str(self) == str(o)
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return "{} {} {} {} {} {}".format(self.device,
|
|
||||||
self.mountpoint,
|
|
||||||
self.filesystem,
|
|
||||||
self.options,
|
|
||||||
self.d,
|
|
||||||
self.p)
|
|
||||||
|
|
||||||
DEFAULT_PATH = os.path.join(os.path.sep, 'etc', 'fstab')
|
|
||||||
|
|
||||||
def __init__(self, path=None):
|
|
||||||
if path:
|
|
||||||
self._path = path
|
|
||||||
else:
|
|
||||||
self._path = self.DEFAULT_PATH
|
|
||||||
super(Fstab, self).__init__(self._path, 'rb+')
|
|
||||||
|
|
||||||
def _hydrate_entry(self, line):
|
|
||||||
# NOTE: use split with no arguments to split on any
|
|
||||||
# whitespace including tabs
|
|
||||||
return Fstab.Entry(*filter(
|
|
||||||
lambda x: x not in ('', None),
|
|
||||||
line.strip("\n").split()))
|
|
||||||
|
|
||||||
@property
|
|
||||||
def entries(self):
|
|
||||||
self.seek(0)
|
|
||||||
for line in self.readlines():
|
|
||||||
line = line.decode('us-ascii')
|
|
||||||
try:
|
|
||||||
if line.strip() and not line.strip().startswith("#"):
|
|
||||||
yield self._hydrate_entry(line)
|
|
||||||
except ValueError:
|
|
||||||
pass
|
|
||||||
|
|
||||||
def get_entry_by_attr(self, attr, value):
|
|
||||||
for entry in self.entries:
|
|
||||||
e_attr = getattr(entry, attr)
|
|
||||||
if e_attr == value:
|
|
||||||
return entry
|
|
||||||
return None
|
|
||||||
|
|
||||||
def add_entry(self, entry):
|
|
||||||
if self.get_entry_by_attr('device', entry.device):
|
|
||||||
return False
|
|
||||||
|
|
||||||
self.write((str(entry) + '\n').encode('us-ascii'))
|
|
||||||
self.truncate()
|
|
||||||
return entry
|
|
||||||
|
|
||||||
def remove_entry(self, entry):
|
|
||||||
self.seek(0)
|
|
||||||
|
|
||||||
lines = [l.decode('us-ascii') for l in self.readlines()]
|
|
||||||
|
|
||||||
found = False
|
|
||||||
for index, line in enumerate(lines):
|
|
||||||
if line.strip() and not line.strip().startswith("#"):
|
|
||||||
if self._hydrate_entry(line) == entry:
|
|
||||||
found = True
|
|
||||||
break
|
|
||||||
|
|
||||||
if not found:
|
|
||||||
return False
|
|
||||||
|
|
||||||
lines.remove(line)
|
|
||||||
|
|
||||||
self.seek(0)
|
|
||||||
self.write(''.join(lines).encode('us-ascii'))
|
|
||||||
self.truncate()
|
|
||||||
return True
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def remove_by_mountpoint(cls, mountpoint, path=None):
|
|
||||||
fstab = cls(path=path)
|
|
||||||
entry = fstab.get_entry_by_attr('mountpoint', mountpoint)
|
|
||||||
if entry:
|
|
||||||
return fstab.remove_entry(entry)
|
|
||||||
return False
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def add(cls, device, mountpoint, filesystem, options=None, path=None):
|
|
||||||
return cls(path=path).add_entry(Fstab.Entry(device,
|
|
||||||
mountpoint, filesystem,
|
|
||||||
options=options))
|
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1,72 +0,0 @@
|
|||||||
import subprocess
|
|
||||||
import yum
|
|
||||||
import os
|
|
||||||
|
|
||||||
from charmhelpers.core.strutils import BasicStringComparator
|
|
||||||
|
|
||||||
|
|
||||||
class CompareHostReleases(BasicStringComparator):
|
|
||||||
"""Provide comparisons of Host releases.
|
|
||||||
|
|
||||||
Use in the form of
|
|
||||||
|
|
||||||
if CompareHostReleases(release) > 'trusty':
|
|
||||||
# do something with mitaka
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, item):
|
|
||||||
raise NotImplementedError(
|
|
||||||
"CompareHostReleases() is not implemented for CentOS")
|
|
||||||
|
|
||||||
|
|
||||||
def service_available(service_name):
|
|
||||||
# """Determine whether a system service is available."""
|
|
||||||
if os.path.isdir('/run/systemd/system'):
|
|
||||||
cmd = ['systemctl', 'is-enabled', service_name]
|
|
||||||
else:
|
|
||||||
cmd = ['service', service_name, 'is-enabled']
|
|
||||||
return subprocess.call(cmd) == 0
|
|
||||||
|
|
||||||
|
|
||||||
def add_new_group(group_name, system_group=False, gid=None):
|
|
||||||
cmd = ['groupadd']
|
|
||||||
if gid:
|
|
||||||
cmd.extend(['--gid', str(gid)])
|
|
||||||
if system_group:
|
|
||||||
cmd.append('-r')
|
|
||||||
cmd.append(group_name)
|
|
||||||
subprocess.check_call(cmd)
|
|
||||||
|
|
||||||
|
|
||||||
def lsb_release():
|
|
||||||
"""Return /etc/os-release in a dict."""
|
|
||||||
d = {}
|
|
||||||
with open('/etc/os-release', 'r') as lsb:
|
|
||||||
for l in lsb:
|
|
||||||
s = l.split('=')
|
|
||||||
if len(s) != 2:
|
|
||||||
continue
|
|
||||||
d[s[0].strip()] = s[1].strip()
|
|
||||||
return d
|
|
||||||
|
|
||||||
|
|
||||||
def cmp_pkgrevno(package, revno, pkgcache=None):
|
|
||||||
"""Compare supplied revno with the revno of the installed package.
|
|
||||||
|
|
||||||
* 1 => Installed revno is greater than supplied arg
|
|
||||||
* 0 => Installed revno is the same as supplied arg
|
|
||||||
* -1 => Installed revno is less than supplied arg
|
|
||||||
|
|
||||||
This function imports YumBase function if the pkgcache argument
|
|
||||||
is None.
|
|
||||||
"""
|
|
||||||
if not pkgcache:
|
|
||||||
y = yum.YumBase()
|
|
||||||
packages = y.doPackageLists()
|
|
||||||
pkgcache = {i.Name: i.version for i in packages['installed']}
|
|
||||||
pkg = pkgcache[package]
|
|
||||||
if pkg > revno:
|
|
||||||
return 1
|
|
||||||
if pkg < revno:
|
|
||||||
return -1
|
|
||||||
return 0
|
|
@ -1,91 +0,0 @@
|
|||||||
import subprocess
|
|
||||||
|
|
||||||
from charmhelpers.core.strutils import BasicStringComparator
|
|
||||||
|
|
||||||
|
|
||||||
UBUNTU_RELEASES = (
|
|
||||||
'lucid',
|
|
||||||
'maverick',
|
|
||||||
'natty',
|
|
||||||
'oneiric',
|
|
||||||
'precise',
|
|
||||||
'quantal',
|
|
||||||
'raring',
|
|
||||||
'saucy',
|
|
||||||
'trusty',
|
|
||||||
'utopic',
|
|
||||||
'vivid',
|
|
||||||
'wily',
|
|
||||||
'xenial',
|
|
||||||
'yakkety',
|
|
||||||
'zesty',
|
|
||||||
'artful',
|
|
||||||
'bionic',
|
|
||||||
'cosmic',
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class CompareHostReleases(BasicStringComparator):
|
|
||||||
"""Provide comparisons of Ubuntu releases.
|
|
||||||
|
|
||||||
Use in the form of
|
|
||||||
|
|
||||||
if CompareHostReleases(release) > 'trusty':
|
|
||||||
# do something with mitaka
|
|
||||||
"""
|
|
||||||
_list = UBUNTU_RELEASES
|
|
||||||
|
|
||||||
|
|
||||||
def service_available(service_name):
|
|
||||||
"""Determine whether a system service is available"""
|
|
||||||
try:
|
|
||||||
subprocess.check_output(
|
|
||||||
['service', service_name, 'status'],
|
|
||||||
stderr=subprocess.STDOUT).decode('UTF-8')
|
|
||||||
except subprocess.CalledProcessError as e:
|
|
||||||
return b'unrecognized service' not in e.output
|
|
||||||
else:
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def add_new_group(group_name, system_group=False, gid=None):
|
|
||||||
cmd = ['addgroup']
|
|
||||||
if gid:
|
|
||||||
cmd.extend(['--gid', str(gid)])
|
|
||||||
if system_group:
|
|
||||||
cmd.append('--system')
|
|
||||||
else:
|
|
||||||
cmd.extend([
|
|
||||||
'--group',
|
|
||||||
])
|
|
||||||
cmd.append(group_name)
|
|
||||||
subprocess.check_call(cmd)
|
|
||||||
|
|
||||||
|
|
||||||
def lsb_release():
|
|
||||||
"""Return /etc/lsb-release in a dict"""
|
|
||||||
d = {}
|
|
||||||
with open('/etc/lsb-release', 'r') as lsb:
|
|
||||||
for l in lsb:
|
|
||||||
k, v = l.split('=')
|
|
||||||
d[k.strip()] = v.strip()
|
|
||||||
return d
|
|
||||||
|
|
||||||
|
|
||||||
def cmp_pkgrevno(package, revno, pkgcache=None):
|
|
||||||
"""Compare supplied revno with the revno of the installed package.
|
|
||||||
|
|
||||||
* 1 => Installed revno is greater than supplied arg
|
|
||||||
* 0 => Installed revno is the same as supplied arg
|
|
||||||
* -1 => Installed revno is less than supplied arg
|
|
||||||
|
|
||||||
This function imports apt_cache function from charmhelpers.fetch if
|
|
||||||
the pkgcache argument is None. Be sure to add charmhelpers.fetch if
|
|
||||||
you call this function, or pass an apt_pkg.Cache() instance.
|
|
||||||
"""
|
|
||||||
import apt_pkg
|
|
||||||
if not pkgcache:
|
|
||||||
from charmhelpers.fetch import apt_cache
|
|
||||||
pkgcache = apt_cache()
|
|
||||||
pkg = pkgcache[package]
|
|
||||||
return apt_pkg.version_compare(pkg.current_ver.ver_str, revno)
|
|
@ -1,69 +0,0 @@
|
|||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
# Copyright 2014-2015 Canonical Limited.
|
|
||||||
#
|
|
||||||
# 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.
|
|
||||||
|
|
||||||
import yaml
|
|
||||||
from charmhelpers.core import fstab
|
|
||||||
from charmhelpers.core import sysctl
|
|
||||||
from charmhelpers.core.host import (
|
|
||||||
add_group,
|
|
||||||
add_user_to_group,
|
|
||||||
fstab_mount,
|
|
||||||
mkdir,
|
|
||||||
)
|
|
||||||
from charmhelpers.core.strutils import bytes_from_string
|
|
||||||
from subprocess import check_output
|
|
||||||
|
|
||||||
|
|
||||||
def hugepage_support(user, group='hugetlb', nr_hugepages=256,
|
|
||||||
max_map_count=65536, mnt_point='/run/hugepages/kvm',
|
|
||||||
pagesize='2MB', mount=True, set_shmmax=False):
|
|
||||||
"""Enable hugepages on system.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
user (str) -- Username to allow access to hugepages to
|
|
||||||
group (str) -- Group name to own hugepages
|
|
||||||
nr_hugepages (int) -- Number of pages to reserve
|
|
||||||
max_map_count (int) -- Number of Virtual Memory Areas a process can own
|
|
||||||
mnt_point (str) -- Directory to mount hugepages on
|
|
||||||
pagesize (str) -- Size of hugepages
|
|
||||||
mount (bool) -- Whether to Mount hugepages
|
|
||||||
"""
|
|
||||||
group_info = add_group(group)
|
|
||||||
gid = group_info.gr_gid
|
|
||||||
add_user_to_group(user, group)
|
|
||||||
if max_map_count < 2 * nr_hugepages:
|
|
||||||
max_map_count = 2 * nr_hugepages
|
|
||||||
sysctl_settings = {
|
|
||||||
'vm.nr_hugepages': nr_hugepages,
|
|
||||||
'vm.max_map_count': max_map_count,
|
|
||||||
'vm.hugetlb_shm_group': gid,
|
|
||||||
}
|
|
||||||
if set_shmmax:
|
|
||||||
shmmax_current = int(check_output(['sysctl', '-n', 'kernel.shmmax']))
|
|
||||||
shmmax_minsize = bytes_from_string(pagesize) * nr_hugepages
|
|
||||||
if shmmax_minsize > shmmax_current:
|
|
||||||
sysctl_settings['kernel.shmmax'] = shmmax_minsize
|
|
||||||
sysctl.create(yaml.dump(sysctl_settings), '/etc/sysctl.d/10-hugepage.conf')
|
|
||||||
mkdir(mnt_point, owner='root', group='root', perms=0o755, force=False)
|
|
||||||
lfstab = fstab.Fstab()
|
|
||||||
fstab_entry = lfstab.get_entry_by_attr('mountpoint', mnt_point)
|
|
||||||
if fstab_entry:
|
|
||||||
lfstab.remove_entry(fstab_entry)
|
|
||||||
entry = lfstab.Entry('nodev', mnt_point, 'hugetlbfs',
|
|
||||||
'mode=1770,gid={},pagesize={}'.format(gid, pagesize), 0, 0)
|
|
||||||
lfstab.add_entry(entry)
|
|
||||||
if mount:
|
|
||||||
fstab_mount(mnt_point)
|
|
@ -1,72 +0,0 @@
|
|||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
# Copyright 2014-2015 Canonical Limited.
|
|
||||||
#
|
|
||||||
# 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.
|
|
||||||
|
|
||||||
import re
|
|
||||||
import subprocess
|
|
||||||
|
|
||||||
from charmhelpers.osplatform import get_platform
|
|
||||||
from charmhelpers.core.hookenv import (
|
|
||||||
log,
|
|
||||||
INFO
|
|
||||||
)
|
|
||||||
|
|
||||||
__platform__ = get_platform()
|
|
||||||
if __platform__ == "ubuntu":
|
|
||||||
from charmhelpers.core.kernel_factory.ubuntu import (
|
|
||||||
persistent_modprobe,
|
|
||||||
update_initramfs,
|
|
||||||
) # flake8: noqa -- ignore F401 for this import
|
|
||||||
elif __platform__ == "centos":
|
|
||||||
from charmhelpers.core.kernel_factory.centos import (
|
|
||||||
persistent_modprobe,
|
|
||||||
update_initramfs,
|
|
||||||
) # flake8: noqa -- ignore F401 for this import
|
|
||||||
|
|
||||||
__author__ = "Jorge Niedbalski <jorge.niedbalski@canonical.com>"
|
|
||||||
|
|
||||||
|
|
||||||
def modprobe(module, persist=True):
|
|
||||||
"""Load a kernel module and configure for auto-load on reboot."""
|
|
||||||
cmd = ['modprobe', module]
|
|
||||||
|
|
||||||
log('Loading kernel module %s' % module, level=INFO)
|
|
||||||
|
|
||||||
subprocess.check_call(cmd)
|
|
||||||
if persist:
|
|
||||||
persistent_modprobe(module)
|
|
||||||
|
|
||||||
|
|
||||||
def rmmod(module, force=False):
|
|
||||||
"""Remove a module from the linux kernel"""
|
|
||||||
cmd = ['rmmod']
|
|
||||||
if force:
|
|
||||||
cmd.append('-f')
|
|
||||||
cmd.append(module)
|
|
||||||
log('Removing kernel module %s' % module, level=INFO)
|
|
||||||
return subprocess.check_call(cmd)
|
|
||||||
|
|
||||||
|
|
||||||
def lsmod():
|
|
||||||
"""Shows what kernel modules are currently loaded"""
|
|
||||||
return subprocess.check_output(['lsmod'],
|
|
||||||
universal_newlines=True)
|
|
||||||
|
|
||||||
|
|
||||||
def is_module_loaded(module):
|
|
||||||
"""Checks if a kernel module is already loaded"""
|
|
||||||
matches = re.findall('^%s[ ]+' % module, lsmod(), re.M)
|
|
||||||
return len(matches) > 0
|
|
@ -1,17 +0,0 @@
|
|||||||
import subprocess
|
|
||||||
import os
|
|
||||||
|
|
||||||
|
|
||||||
def persistent_modprobe(module):
|
|
||||||
"""Load a kernel module and configure for auto-load on reboot."""
|
|
||||||
if not os.path.exists('/etc/rc.modules'):
|
|
||||||
open('/etc/rc.modules', 'a')
|
|
||||||
os.chmod('/etc/rc.modules', 111)
|
|
||||||
with open('/etc/rc.modules', 'r+') as modules:
|
|
||||||
if module not in modules.read():
|
|
||||||
modules.write('modprobe %s\n' % module)
|
|
||||||
|
|
||||||
|
|
||||||
def update_initramfs(version='all'):
|
|
||||||
"""Updates an initramfs image."""
|
|
||||||
return subprocess.check_call(["dracut", "-f", version])
|
|
@ -1,13 +0,0 @@
|
|||||||
import subprocess
|
|
||||||
|
|
||||||
|
|
||||||
def persistent_modprobe(module):
|
|
||||||
"""Load a kernel module and configure for auto-load on reboot."""
|
|
||||||
with open('/etc/modules', 'r+') as modules:
|
|
||||||
if module not in modules.read():
|
|
||||||
modules.write(module + "\n")
|
|
||||||
|
|
||||||
|
|
||||||
def update_initramfs(version='all'):
|
|
||||||
"""Updates an initramfs image."""
|
|
||||||
return subprocess.check_call(["update-initramfs", "-k", version, "-u"])
|
|
@ -1,16 +0,0 @@
|
|||||||
# Copyright 2014-2015 Canonical Limited.
|
|
||||||
#
|
|
||||||
# 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 .base import * # NOQA
|
|
||||||
from .helpers import * # NOQA
|
|
@ -1,362 +0,0 @@
|
|||||||
# Copyright 2014-2015 Canonical Limited.
|
|
||||||
#
|
|
||||||
# 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.
|
|
||||||
|
|
||||||
import os
|
|
||||||
import json
|
|
||||||
from inspect import getargspec
|
|
||||||
from collections import Iterable, OrderedDict
|
|
||||||
|
|
||||||
from charmhelpers.core import host
|
|
||||||
from charmhelpers.core import hookenv
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = ['ServiceManager', 'ManagerCallback',
|
|
||||||
'PortManagerCallback', 'open_ports', 'close_ports', 'manage_ports',
|
|
||||||
'service_restart', 'service_stop']
|
|
||||||
|
|
||||||
|
|
||||||
class ServiceManager(object):
|
|
||||||
def __init__(self, services=None):
|
|
||||||
"""
|
|
||||||
Register a list of services, given their definitions.
|
|
||||||
|
|
||||||
Service definitions are dicts in the following formats (all keys except
|
|
||||||
'service' are optional)::
|
|
||||||
|
|
||||||
{
|
|
||||||
"service": <service name>,
|
|
||||||
"required_data": <list of required data contexts>,
|
|
||||||
"provided_data": <list of provided data contexts>,
|
|
||||||
"data_ready": <one or more callbacks>,
|
|
||||||
"data_lost": <one or more callbacks>,
|
|
||||||
"start": <one or more callbacks>,
|
|
||||||
"stop": <one or more callbacks>,
|
|
||||||
"ports": <list of ports to manage>,
|
|
||||||
}
|
|
||||||
|
|
||||||
The 'required_data' list should contain dicts of required data (or
|
|
||||||
dependency managers that act like dicts and know how to collect the data).
|
|
||||||
Only when all items in the 'required_data' list are populated are the list
|
|
||||||
of 'data_ready' and 'start' callbacks executed. See `is_ready()` for more
|
|
||||||
information.
|
|
||||||
|
|
||||||
The 'provided_data' list should contain relation data providers, most likely
|
|
||||||
a subclass of :class:`charmhelpers.core.services.helpers.RelationContext`,
|
|
||||||
that will indicate a set of data to set on a given relation.
|
|
||||||
|
|
||||||
The 'data_ready' value should be either a single callback, or a list of
|
|
||||||
callbacks, to be called when all items in 'required_data' pass `is_ready()`.
|
|
||||||
Each callback will be called with the service name as the only parameter.
|
|
||||||
After all of the 'data_ready' callbacks are called, the 'start' callbacks
|
|
||||||
are fired.
|
|
||||||
|
|
||||||
The 'data_lost' value should be either a single callback, or a list of
|
|
||||||
callbacks, to be called when a 'required_data' item no longer passes
|
|
||||||
`is_ready()`. Each callback will be called with the service name as the
|
|
||||||
only parameter. After all of the 'data_lost' callbacks are called,
|
|
||||||
the 'stop' callbacks are fired.
|
|
||||||
|
|
||||||
The 'start' value should be either a single callback, or a list of
|
|
||||||
callbacks, to be called when starting the service, after the 'data_ready'
|
|
||||||
callbacks are complete. Each callback will be called with the service
|
|
||||||
name as the only parameter. This defaults to
|
|
||||||
`[host.service_start, services.open_ports]`.
|
|
||||||
|
|
||||||
The 'stop' value should be either a single callback, or a list of
|
|
||||||
callbacks, to be called when stopping the service. If the service is
|
|
||||||
being stopped because it no longer has all of its 'required_data', this
|
|
||||||
will be called after all of the 'data_lost' callbacks are complete.
|
|
||||||
Each callback will be called with the service name as the only parameter.
|
|
||||||
This defaults to `[services.close_ports, host.service_stop]`.
|
|
||||||
|
|
||||||
The 'ports' value should be a list of ports to manage. The default
|
|
||||||
'start' handler will open the ports after the service is started,
|
|
||||||
and the default 'stop' handler will close the ports prior to stopping
|
|
||||||
the service.
|
|
||||||
|
|
||||||
|
|
||||||
Examples:
|
|
||||||
|
|
||||||
The following registers an Upstart service called bingod that depends on
|
|
||||||
a mongodb relation and which runs a custom `db_migrate` function prior to
|
|
||||||
restarting the service, and a Runit service called spadesd::
|
|
||||||
|
|
||||||
manager = services.ServiceManager([
|
|
||||||
{
|
|
||||||
'service': 'bingod',
|
|
||||||
'ports': [80, 443],
|
|
||||||
'required_data': [MongoRelation(), config(), {'my': 'data'}],
|
|
||||||
'data_ready': [
|
|
||||||
services.template(source='bingod.conf'),
|
|
||||||
services.template(source='bingod.ini',
|
|
||||||
target='/etc/bingod.ini',
|
|
||||||
owner='bingo', perms=0400),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'service': 'spadesd',
|
|
||||||
'data_ready': services.template(source='spadesd_run.j2',
|
|
||||||
target='/etc/sv/spadesd/run',
|
|
||||||
perms=0555),
|
|
||||||
'start': runit_start,
|
|
||||||
'stop': runit_stop,
|
|
||||||
},
|
|
||||||
])
|
|
||||||
manager.manage()
|
|
||||||
"""
|
|
||||||
self._ready_file = os.path.join(hookenv.charm_dir(), 'READY-SERVICES.json')
|
|
||||||
self._ready = None
|
|
||||||
self.services = OrderedDict()
|
|
||||||
for service in services or []:
|
|
||||||
service_name = service['service']
|
|
||||||
self.services[service_name] = service
|
|
||||||
|
|
||||||
def manage(self):
|
|
||||||
"""
|
|
||||||
Handle the current hook by doing The Right Thing with the registered services.
|
|
||||||
"""
|
|
||||||
hookenv._run_atstart()
|
|
||||||
try:
|
|
||||||
hook_name = hookenv.hook_name()
|
|
||||||
if hook_name == 'stop':
|
|
||||||
self.stop_services()
|
|
||||||
else:
|
|
||||||
self.reconfigure_services()
|
|
||||||
self.provide_data()
|
|
||||||
except SystemExit as x:
|
|
||||||
if x.code is None or x.code == 0:
|
|
||||||
hookenv._run_atexit()
|
|
||||||
hookenv._run_atexit()
|
|
||||||
|
|
||||||
def provide_data(self):
|
|
||||||
"""
|
|
||||||
Set the relation data for each provider in the ``provided_data`` list.
|
|
||||||
|
|
||||||
A provider must have a `name` attribute, which indicates which relation
|
|
||||||
to set data on, and a `provide_data()` method, which returns a dict of
|
|
||||||
data to set.
|
|
||||||
|
|
||||||
The `provide_data()` method can optionally accept two parameters:
|
|
||||||
|
|
||||||
* ``remote_service`` The name of the remote service that the data will
|
|
||||||
be provided to. The `provide_data()` method will be called once
|
|
||||||
for each connected service (not unit). This allows the method to
|
|
||||||
tailor its data to the given service.
|
|
||||||
* ``service_ready`` Whether or not the service definition had all of
|
|
||||||
its requirements met, and thus the ``data_ready`` callbacks run.
|
|
||||||
|
|
||||||
Note that the ``provided_data`` methods are now called **after** the
|
|
||||||
``data_ready`` callbacks are run. This gives the ``data_ready`` callbacks
|
|
||||||
a chance to generate any data necessary for the providing to the remote
|
|
||||||
services.
|
|
||||||
"""
|
|
||||||
for service_name, service in self.services.items():
|
|
||||||
service_ready = self.is_ready(service_name)
|
|
||||||
for provider in service.get('provided_data', []):
|
|
||||||
for relid in hookenv.relation_ids(provider.name):
|
|
||||||
units = hookenv.related_units(relid)
|
|
||||||
if not units:
|
|
||||||
continue
|
|
||||||
remote_service = units[0].split('/')[0]
|
|
||||||
argspec = getargspec(provider.provide_data)
|
|
||||||
if len(argspec.args) > 1:
|
|
||||||
data = provider.provide_data(remote_service, service_ready)
|
|
||||||
else:
|
|
||||||
data = provider.provide_data()
|
|
||||||
if data:
|
|
||||||
hookenv.relation_set(relid, data)
|
|
||||||
|
|
||||||
def reconfigure_services(self, *service_names):
|
|
||||||
"""
|
|
||||||
Update all files for one or more registered services, and,
|
|
||||||
if ready, optionally restart them.
|
|
||||||
|
|
||||||
If no service names are given, reconfigures all registered services.
|
|
||||||
"""
|
|
||||||
for service_name in service_names or self.services.keys():
|
|
||||||
if self.is_ready(service_name):
|
|
||||||
self.fire_event('data_ready', service_name)
|
|
||||||
self.fire_event('start', service_name, default=[
|
|
||||||
service_restart,
|
|
||||||
manage_ports])
|
|
||||||
self.save_ready(service_name)
|
|
||||||
else:
|
|
||||||
if self.was_ready(service_name):
|
|
||||||
self.fire_event('data_lost', service_name)
|
|
||||||
self.fire_event('stop', service_name, default=[
|
|
||||||
manage_ports,
|
|
||||||
service_stop])
|
|
||||||
self.save_lost(service_name)
|
|
||||||
|
|
||||||
def stop_services(self, *service_names):
|
|
||||||
"""
|
|
||||||
Stop one or more registered services, by name.
|
|
||||||
|
|
||||||
If no service names are given, stops all registered services.
|
|
||||||
"""
|
|
||||||
for service_name in service_names or self.services.keys():
|
|
||||||
self.fire_event('stop', service_name, default=[
|
|
||||||
manage_ports,
|
|
||||||
service_stop])
|
|
||||||
|
|
||||||
def get_service(self, service_name):
|
|
||||||
"""
|
|
||||||
Given the name of a registered service, return its service definition.
|
|
||||||
"""
|
|
||||||
service = self.services.get(service_name)
|
|
||||||
if not service:
|
|
||||||
raise KeyError('Service not registered: %s' % service_name)
|
|
||||||
return service
|
|
||||||
|
|
||||||
def fire_event(self, event_name, service_name, default=None):
|
|
||||||
"""
|
|
||||||
Fire a data_ready, data_lost, start, or stop event on a given service.
|
|
||||||
"""
|
|
||||||
service = self.get_service(service_name)
|
|
||||||
callbacks = service.get(event_name, default)
|
|
||||||
if not callbacks:
|
|
||||||
return
|
|
||||||
if not isinstance(callbacks, Iterable):
|
|
||||||
callbacks = [callbacks]
|
|
||||||
for callback in callbacks:
|
|
||||||
if isinstance(callback, ManagerCallback):
|
|
||||||
callback(self, service_name, event_name)
|
|
||||||
else:
|
|
||||||
callback(service_name)
|
|
||||||
|
|
||||||
def is_ready(self, service_name):
|
|
||||||
"""
|
|
||||||
Determine if a registered service is ready, by checking its 'required_data'.
|
|
||||||
|
|
||||||
A 'required_data' item can be any mapping type, and is considered ready
|
|
||||||
if `bool(item)` evaluates as True.
|
|
||||||
"""
|
|
||||||
service = self.get_service(service_name)
|
|
||||||
reqs = service.get('required_data', [])
|
|
||||||
return all(bool(req) for req in reqs)
|
|
||||||
|
|
||||||
def _load_ready_file(self):
|
|
||||||
if self._ready is not None:
|
|
||||||
return
|
|
||||||
if os.path.exists(self._ready_file):
|
|
||||||
with open(self._ready_file) as fp:
|
|
||||||
self._ready = set(json.load(fp))
|
|
||||||
else:
|
|
||||||
self._ready = set()
|
|
||||||
|
|
||||||
def _save_ready_file(self):
|
|
||||||
if self._ready is None:
|
|
||||||
return
|
|
||||||
with open(self._ready_file, 'w') as fp:
|
|
||||||
json.dump(list(self._ready), fp)
|
|
||||||
|
|
||||||
def save_ready(self, service_name):
|
|
||||||
"""
|
|
||||||
Save an indicator that the given service is now data_ready.
|
|
||||||
"""
|
|
||||||
self._load_ready_file()
|
|
||||||
self._ready.add(service_name)
|
|
||||||
self._save_ready_file()
|
|
||||||
|
|
||||||
def save_lost(self, service_name):
|
|
||||||
"""
|
|
||||||
Save an indicator that the given service is no longer data_ready.
|
|
||||||
"""
|
|
||||||
self._load_ready_file()
|
|
||||||
self._ready.discard(service_name)
|
|
||||||
self._save_ready_file()
|
|
||||||
|
|
||||||
def was_ready(self, service_name):
|
|
||||||
"""
|
|
||||||
Determine if the given service was previously data_ready.
|
|
||||||
"""
|
|
||||||
self._load_ready_file()
|
|
||||||
return service_name in self._ready
|
|
||||||
|
|
||||||
|
|
||||||
class ManagerCallback(object):
|
|
||||||
"""
|
|
||||||
Special case of a callback that takes the `ServiceManager` instance
|
|
||||||
in addition to the service name.
|
|
||||||
|
|
||||||
Subclasses should implement `__call__` which should accept three parameters:
|
|
||||||
|
|
||||||
* `manager` The `ServiceManager` instance
|
|
||||||
* `service_name` The name of the service it's being triggered for
|
|
||||||
* `event_name` The name of the event that this callback is handling
|
|
||||||
"""
|
|
||||||
def __call__(self, manager, service_name, event_name):
|
|
||||||
raise NotImplementedError()
|
|
||||||
|
|
||||||
|
|
||||||
class PortManagerCallback(ManagerCallback):
|
|
||||||
"""
|
|
||||||
Callback class that will open or close ports, for use as either
|
|
||||||
a start or stop action.
|
|
||||||
"""
|
|
||||||
def __call__(self, manager, service_name, event_name):
|
|
||||||
service = manager.get_service(service_name)
|
|
||||||
# turn this generator into a list,
|
|
||||||
# as we'll be going over it multiple times
|
|
||||||
new_ports = list(service.get('ports', []))
|
|
||||||
port_file = os.path.join(hookenv.charm_dir(), '.{}.ports'.format(service_name))
|
|
||||||
if os.path.exists(port_file):
|
|
||||||
with open(port_file) as fp:
|
|
||||||
old_ports = fp.read().split(',')
|
|
||||||
for old_port in old_ports:
|
|
||||||
if bool(old_port) and not self.ports_contains(old_port, new_ports):
|
|
||||||
hookenv.close_port(old_port)
|
|
||||||
with open(port_file, 'w') as fp:
|
|
||||||
fp.write(','.join(str(port) for port in new_ports))
|
|
||||||
for port in new_ports:
|
|
||||||
# A port is either a number or 'ICMP'
|
|
||||||
protocol = 'TCP'
|
|
||||||
if str(port).upper() == 'ICMP':
|
|
||||||
protocol = 'ICMP'
|
|
||||||
if event_name == 'start':
|
|
||||||
hookenv.open_port(port, protocol)
|
|
||||||
elif event_name == 'stop':
|
|
||||||
hookenv.close_port(port, protocol)
|
|
||||||
|
|
||||||
def ports_contains(self, port, ports):
|
|
||||||
if not bool(port):
|
|
||||||
return False
|
|
||||||
if str(port).upper() != 'ICMP':
|
|
||||||
port = int(port)
|
|
||||||
return port in ports
|
|
||||||
|
|
||||||
|
|
||||||
def service_stop(service_name):
|
|
||||||
"""
|
|
||||||
Wrapper around host.service_stop to prevent spurious "unknown service"
|
|
||||||
messages in the logs.
|
|
||||||
"""
|
|
||||||
if host.service_running(service_name):
|
|
||||||
host.service_stop(service_name)
|
|
||||||
|
|
||||||
|
|
||||||
def service_restart(service_name):
|
|
||||||
"""
|
|
||||||
Wrapper around host.service_restart to prevent spurious "unknown service"
|
|
||||||
messages in the logs.
|
|
||||||
"""
|
|
||||||
if host.service_available(service_name):
|
|
||||||
if host.service_running(service_name):
|
|
||||||
host.service_restart(service_name)
|
|
||||||
else:
|
|
||||||
host.service_start(service_name)
|
|
||||||
|
|
||||||
|
|
||||||
# Convenience aliases
|
|
||||||
open_ports = close_ports = manage_ports = PortManagerCallback()
|
|
@ -1,290 +0,0 @@
|
|||||||
# Copyright 2014-2015 Canonical Limited.
|
|
||||||
#
|
|
||||||
# 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.
|
|
||||||
|
|
||||||
import os
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
from charmhelpers.core import hookenv
|
|
||||||
from charmhelpers.core import host
|
|
||||||
from charmhelpers.core import templating
|
|
||||||
|
|
||||||
from charmhelpers.core.services.base import ManagerCallback
|
|
||||||
|
|
||||||
|
|
||||||
__all__ = ['RelationContext', 'TemplateCallback',
|
|
||||||
'render_template', 'template']
|
|
||||||
|
|
||||||
|
|
||||||
class RelationContext(dict):
|
|
||||||
"""
|
|
||||||
Base class for a context generator that gets relation data from juju.
|
|
||||||
|
|
||||||
Subclasses must provide the attributes `name`, which is the name of the
|
|
||||||
interface of interest, `interface`, which is the type of the interface of
|
|
||||||
interest, and `required_keys`, which is the set of keys required for the
|
|
||||||
relation to be considered complete. The data for all interfaces matching
|
|
||||||
the `name` attribute that are complete will used to populate the dictionary
|
|
||||||
values (see `get_data`, below).
|
|
||||||
|
|
||||||
The generated context will be namespaced under the relation :attr:`name`,
|
|
||||||
to prevent potential naming conflicts.
|
|
||||||
|
|
||||||
:param str name: Override the relation :attr:`name`, since it can vary from charm to charm
|
|
||||||
:param list additional_required_keys: Extend the list of :attr:`required_keys`
|
|
||||||
"""
|
|
||||||
name = None
|
|
||||||
interface = None
|
|
||||||
|
|
||||||
def __init__(self, name=None, additional_required_keys=None):
|
|
||||||
if not hasattr(self, 'required_keys'):
|
|
||||||
self.required_keys = []
|
|
||||||
|
|
||||||
if name is not None:
|
|
||||||
self.name = name
|
|
||||||
if additional_required_keys:
|
|
||||||
self.required_keys.extend(additional_required_keys)
|
|
||||||
self.get_data()
|
|
||||||
|
|
||||||
def __bool__(self):
|
|
||||||
"""
|
|
||||||
Returns True if all of the required_keys are available.
|
|
||||||
"""
|
|
||||||
return self.is_ready()
|
|
||||||
|
|
||||||
__nonzero__ = __bool__
|
|
||||||
|
|
||||||
def __repr__(self):
|
|
||||||
return super(RelationContext, self).__repr__()
|
|
||||||
|
|
||||||
def is_ready(self):
|
|
||||||
"""
|
|
||||||
Returns True if all of the `required_keys` are available from any units.
|
|
||||||
"""
|
|
||||||
ready = len(self.get(self.name, [])) > 0
|
|
||||||
if not ready:
|
|
||||||
hookenv.log('Incomplete relation: {}'.format(self.__class__.__name__), hookenv.DEBUG)
|
|
||||||
return ready
|
|
||||||
|
|
||||||
def _is_ready(self, unit_data):
|
|
||||||
"""
|
|
||||||
Helper method that tests a set of relation data and returns True if
|
|
||||||
all of the `required_keys` are present.
|
|
||||||
"""
|
|
||||||
return set(unit_data.keys()).issuperset(set(self.required_keys))
|
|
||||||
|
|
||||||
def get_data(self):
|
|
||||||
"""
|
|
||||||
Retrieve the relation data for each unit involved in a relation and,
|
|
||||||
if complete, store it in a list under `self[self.name]`. This
|
|
||||||
is automatically called when the RelationContext is instantiated.
|
|
||||||
|
|
||||||
The units are sorted lexographically first by the service ID, then by
|
|
||||||
the unit ID. Thus, if an interface has two other services, 'db:1'
|
|
||||||
and 'db:2', with 'db:1' having two units, 'wordpress/0' and 'wordpress/1',
|
|
||||||
and 'db:2' having one unit, 'mediawiki/0', all of which have a complete
|
|
||||||
set of data, the relation data for the units will be stored in the
|
|
||||||
order: 'wordpress/0', 'wordpress/1', 'mediawiki/0'.
|
|
||||||
|
|
||||||
If you only care about a single unit on the relation, you can just
|
|
||||||
access it as `{{ interface[0]['key'] }}`. However, if you can at all
|
|
||||||
support multiple units on a relation, you should iterate over the list,
|
|
||||||
like::
|
|
||||||
|
|
||||||
{% for unit in interface -%}
|
|
||||||
{{ unit['key'] }}{% if not loop.last %},{% endif %}
|
|
||||||
{%- endfor %}
|
|
||||||
|
|
||||||
Note that since all sets of relation data from all related services and
|
|
||||||
units are in a single list, if you need to know which service or unit a
|
|
||||||
set of data came from, you'll need to extend this class to preserve
|
|
||||||
that information.
|
|
||||||
"""
|
|
||||||
if not hookenv.relation_ids(self.name):
|
|
||||||
return
|
|
||||||
|
|
||||||
ns = self.setdefault(self.name, [])
|
|
||||||
for rid in sorted(hookenv.relation_ids(self.name)):
|
|
||||||
for unit in sorted(hookenv.related_units(rid)):
|
|
||||||
reldata = hookenv.relation_get(rid=rid, unit=unit)
|
|
||||||
if self._is_ready(reldata):
|
|
||||||
ns.append(reldata)
|
|
||||||
|
|
||||||
def provide_data(self):
|
|
||||||
"""
|
|
||||||
Return data to be relation_set for this interface.
|
|
||||||
"""
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
class MysqlRelation(RelationContext):
|
|
||||||
"""
|
|
||||||
Relation context for the `mysql` interface.
|
|
||||||
|
|
||||||
:param str name: Override the relation :attr:`name`, since it can vary from charm to charm
|
|
||||||
:param list additional_required_keys: Extend the list of :attr:`required_keys`
|
|
||||||
"""
|
|
||||||
name = 'db'
|
|
||||||
interface = 'mysql'
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
self.required_keys = ['host', 'user', 'password', 'database']
|
|
||||||
RelationContext.__init__(self, *args, **kwargs)
|
|
||||||
|
|
||||||
|
|
||||||
class HttpRelation(RelationContext):
|
|
||||||
"""
|
|
||||||
Relation context for the `http` interface.
|
|
||||||
|
|
||||||
:param str name: Override the relation :attr:`name`, since it can vary from charm to charm
|
|
||||||
:param list additional_required_keys: Extend the list of :attr:`required_keys`
|
|
||||||
"""
|
|
||||||
name = 'website'
|
|
||||||
interface = 'http'
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
self.required_keys = ['host', 'port']
|
|
||||||
RelationContext.__init__(self, *args, **kwargs)
|
|
||||||
|
|
||||||
def provide_data(self):
|
|
||||||
return {
|
|
||||||
'host': hookenv.unit_get('private-address'),
|
|
||||||
'port': 80,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class RequiredConfig(dict):
|
|
||||||
"""
|
|
||||||
Data context that loads config options with one or more mandatory options.
|
|
||||||
|
|
||||||
Once the required options have been changed from their default values, all
|
|
||||||
config options will be available, namespaced under `config` to prevent
|
|
||||||
potential naming conflicts (for example, between a config option and a
|
|
||||||
relation property).
|
|
||||||
|
|
||||||
:param list *args: List of options that must be changed from their default values.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, *args):
|
|
||||||
self.required_options = args
|
|
||||||
self['config'] = hookenv.config()
|
|
||||||
with open(os.path.join(hookenv.charm_dir(), 'config.yaml')) as fp:
|
|
||||||
self.config = yaml.load(fp).get('options', {})
|
|
||||||
|
|
||||||
def __bool__(self):
|
|
||||||
for option in self.required_options:
|
|
||||||
if option not in self['config']:
|
|
||||||
return False
|
|
||||||
current_value = self['config'][option]
|
|
||||||
default_value = self.config[option].get('default')
|
|
||||||
if current_value == default_value:
|
|
||||||
return False
|
|
||||||
if current_value in (None, '') and default_value in (None, ''):
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
def __nonzero__(self):
|
|
||||||
return self.__bool__()
|
|
||||||
|
|
||||||
|
|
||||||
class StoredContext(dict):
|
|
||||||
"""
|
|
||||||
A data context that always returns the data that it was first created with.
|
|
||||||
|
|
||||||
This is useful to do a one-time generation of things like passwords, that
|
|
||||||
will thereafter use the same value that was originally generated, instead
|
|
||||||
of generating a new value each time it is run.
|
|
||||||
"""
|
|
||||||
def __init__(self, file_name, config_data):
|
|
||||||
"""
|
|
||||||
If the file exists, populate `self` with the data from the file.
|
|
||||||
Otherwise, populate with the given data and persist it to the file.
|
|
||||||
"""
|
|
||||||
if os.path.exists(file_name):
|
|
||||||
self.update(self.read_context(file_name))
|
|
||||||
else:
|
|
||||||
self.store_context(file_name, config_data)
|
|
||||||
self.update(config_data)
|
|
||||||
|
|
||||||
def store_context(self, file_name, config_data):
|
|
||||||
if not os.path.isabs(file_name):
|
|
||||||
file_name = os.path.join(hookenv.charm_dir(), file_name)
|
|
||||||
with open(file_name, 'w') as file_stream:
|
|
||||||
os.fchmod(file_stream.fileno(), 0o600)
|
|
||||||
yaml.dump(config_data, file_stream)
|
|
||||||
|
|
||||||
def read_context(self, file_name):
|
|
||||||
if not os.path.isabs(file_name):
|
|
||||||
file_name = os.path.join(hookenv.charm_dir(), file_name)
|
|
||||||
with open(file_name, 'r') as file_stream:
|
|
||||||
data = yaml.load(file_stream)
|
|
||||||
if not data:
|
|
||||||
raise OSError("%s is empty" % file_name)
|
|
||||||
return data
|
|
||||||
|
|
||||||
|
|
||||||
class TemplateCallback(ManagerCallback):
|
|
||||||
"""
|
|
||||||
Callback class that will render a Jinja2 template, for use as a ready
|
|
||||||
action.
|
|
||||||
|
|
||||||
:param str source: The template source file, relative to
|
|
||||||
`$CHARM_DIR/templates`
|
|
||||||
|
|
||||||
:param str target: The target to write the rendered template to (or None)
|
|
||||||
:param str owner: The owner of the rendered file
|
|
||||||
:param str group: The group of the rendered file
|
|
||||||
:param int perms: The permissions of the rendered file
|
|
||||||
:param partial on_change_action: functools partial to be executed when
|
|
||||||
rendered file changes
|
|
||||||
:param jinja2 loader template_loader: A jinja2 template loader
|
|
||||||
|
|
||||||
:return str: The rendered template
|
|
||||||
"""
|
|
||||||
def __init__(self, source, target,
|
|
||||||
owner='root', group='root', perms=0o444,
|
|
||||||
on_change_action=None, template_loader=None):
|
|
||||||
self.source = source
|
|
||||||
self.target = target
|
|
||||||
self.owner = owner
|
|
||||||
self.group = group
|
|
||||||
self.perms = perms
|
|
||||||
self.on_change_action = on_change_action
|
|
||||||
self.template_loader = template_loader
|
|
||||||
|
|
||||||
def __call__(self, manager, service_name, event_name):
|
|
||||||
pre_checksum = ''
|
|
||||||
if self.on_change_action and os.path.isfile(self.target):
|
|
||||||
pre_checksum = host.file_hash(self.target)
|
|
||||||
service = manager.get_service(service_name)
|
|
||||||
context = {'ctx': {}}
|
|
||||||
for ctx in service.get('required_data', []):
|
|
||||||
context.update(ctx)
|
|
||||||
context['ctx'].update(ctx)
|
|
||||||
|
|
||||||
result = templating.render(self.source, self.target, context,
|
|
||||||
self.owner, self.group, self.perms,
|
|
||||||
template_loader=self.template_loader)
|
|
||||||
if self.on_change_action:
|
|
||||||
if pre_checksum == host.file_hash(self.target):
|
|
||||||
hookenv.log(
|
|
||||||
'No change detected: {}'.format(self.target),
|
|
||||||
hookenv.DEBUG)
|
|
||||||
else:
|
|
||||||
self.on_change_action()
|
|
||||||
|
|
||||||
return result
|
|
||||||
|
|
||||||
|
|
||||||
# Convenience aliases for templates
|
|
||||||
render_template = template = TemplateCallback
|
|
@ -1,129 +0,0 @@
|
|||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
# Copyright 2014-2015 Canonical Limited.
|
|
||||||
#
|
|
||||||
# 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.
|
|
||||||
|
|
||||||
import six
|
|
||||||
import re
|
|
||||||
|
|
||||||
|
|
||||||
def bool_from_string(value):
|
|
||||||
"""Interpret string value as boolean.
|
|
||||||
|
|
||||||
Returns True if value translates to True otherwise False.
|
|
||||||
"""
|
|
||||||
if isinstance(value, six.string_types):
|
|
||||||
value = six.text_type(value)
|
|
||||||
else:
|
|
||||||
msg = "Unable to interpret non-string value '%s' as boolean" % (value)
|
|
||||||
raise ValueError(msg)
|
|
||||||
|
|
||||||
value = value.strip().lower()
|
|
||||||
|
|
||||||
if value in ['y', 'yes', 'true', 't', 'on']:
|
|
||||||
return True
|
|
||||||
elif value in ['n', 'no', 'false', 'f', 'off']:
|
|
||||||
return False
|
|
||||||
|
|
||||||
msg = "Unable to interpret string value '%s' as boolean" % (value)
|
|
||||||
raise ValueError(msg)
|
|
||||||
|
|
||||||
|
|
||||||
def bytes_from_string(value):
|
|
||||||
"""Interpret human readable string value as bytes.
|
|
||||||
|
|
||||||
Returns int
|
|
||||||
"""
|
|
||||||
BYTE_POWER = {
|
|
||||||
'K': 1,
|
|
||||||
'KB': 1,
|
|
||||||
'M': 2,
|
|
||||||
'MB': 2,
|
|
||||||
'G': 3,
|
|
||||||
'GB': 3,
|
|
||||||
'T': 4,
|
|
||||||
'TB': 4,
|
|
||||||
'P': 5,
|
|
||||||
'PB': 5,
|
|
||||||
}
|
|
||||||
if isinstance(value, six.string_types):
|
|
||||||
value = six.text_type(value)
|
|
||||||
else:
|
|
||||||
msg = "Unable to interpret non-string value '%s' as bytes" % (value)
|
|
||||||
raise ValueError(msg)
|
|
||||||
matches = re.match("([0-9]+)([a-zA-Z]+)", value)
|
|
||||||
if matches:
|
|
||||||
size = int(matches.group(1)) * (1024 ** BYTE_POWER[matches.group(2)])
|
|
||||||
else:
|
|
||||||
# Assume that value passed in is bytes
|
|
||||||
try:
|
|
||||||
size = int(value)
|
|
||||||
except ValueError:
|
|
||||||
msg = "Unable to interpret string value '%s' as bytes" % (value)
|
|
||||||
raise ValueError(msg)
|
|
||||||
return size
|
|
||||||
|
|
||||||
|
|
||||||
class BasicStringComparator(object):
|
|
||||||
"""Provides a class that will compare strings from an iterator type object.
|
|
||||||
Used to provide > and < comparisons on strings that may not necessarily be
|
|
||||||
alphanumerically ordered. e.g. OpenStack or Ubuntu releases AFTER the
|
|
||||||
z-wrap.
|
|
||||||
"""
|
|
||||||
|
|
||||||
_list = None
|
|
||||||
|
|
||||||
def __init__(self, item):
|
|
||||||
if self._list is None:
|
|
||||||
raise Exception("Must define the _list in the class definition!")
|
|
||||||
try:
|
|
||||||
self.index = self._list.index(item)
|
|
||||||
except Exception:
|
|
||||||
raise KeyError("Item '{}' is not in list '{}'"
|
|
||||||
.format(item, self._list))
|
|
||||||
|
|
||||||
def __eq__(self, other):
|
|
||||||
assert isinstance(other, str) or isinstance(other, self.__class__)
|
|
||||||
return self.index == self._list.index(other)
|
|
||||||
|
|
||||||
def __ne__(self, other):
|
|
||||||
return not self.__eq__(other)
|
|
||||||
|
|
||||||
def __lt__(self, other):
|
|
||||||
assert isinstance(other, str) or isinstance(other, self.__class__)
|
|
||||||
return self.index < self._list.index(other)
|
|
||||||
|
|
||||||
def __ge__(self, other):
|
|
||||||
return not self.__lt__(other)
|
|
||||||
|
|
||||||
def __gt__(self, other):
|
|
||||||
assert isinstance(other, str) or isinstance(other, self.__class__)
|
|
||||||
return self.index > self._list.index(other)
|
|
||||||
|
|
||||||
def __le__(self, other):
|
|
||||||
return not self.__gt__(other)
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
"""Always give back the item at the index so it can be used in
|
|
||||||
comparisons like:
|
|
||||||
|
|
||||||
s_mitaka = CompareOpenStack('mitaka')
|
|
||||||
s_newton = CompareOpenstack('newton')
|
|
||||||
|
|
||||||
assert s_newton > s_mitaka
|
|
||||||
|
|
||||||
@returns: <string>
|
|
||||||
"""
|
|
||||||
return self._list[self.index]
|
|
@ -1,58 +0,0 @@
|
|||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
|
|
||||||
# Copyright 2014-2015 Canonical Limited.
|
|
||||||
#
|
|
||||||
# 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.
|
|
||||||
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
from subprocess import check_call
|
|
||||||
|
|
||||||
from charmhelpers.core.hookenv import (
|
|
||||||
log,
|
|
||||||
DEBUG,
|
|
||||||
ERROR,
|
|
||||||
)
|
|
||||||
|
|
||||||
__author__ = 'Jorge Niedbalski R. <jorge.niedbalski@canonical.com>'
|
|
||||||
|
|
||||||
|
|
||||||
def create(sysctl_dict, sysctl_file):
|
|
||||||
"""Creates a sysctl.conf file from a YAML associative array
|
|
||||||
|
|
||||||
:param sysctl_dict: a dict or YAML-formatted string of sysctl
|
|
||||||
options eg "{ 'kernel.max_pid': 1337 }"
|
|
||||||
:type sysctl_dict: str
|
|
||||||
:param sysctl_file: path to the sysctl file to be saved
|
|
||||||
:type sysctl_file: str or unicode
|
|
||||||
:returns: None
|
|
||||||
"""
|
|
||||||
if type(sysctl_dict) is not dict:
|
|
||||||
try:
|
|
||||||
sysctl_dict_parsed = yaml.safe_load(sysctl_dict)
|
|
||||||
except yaml.YAMLError:
|
|
||||||
log("Error parsing YAML sysctl_dict: {}".format(sysctl_dict),
|
|
||||||
level=ERROR)
|
|
||||||
return
|
|
||||||
else:
|
|
||||||
sysctl_dict_parsed = sysctl_dict
|
|
||||||
|
|
||||||
with open(sysctl_file, "w") as fd:
|
|
||||||
for key, value in sysctl_dict_parsed.items():
|
|
||||||
fd.write("{}={}\n".format(key, value))
|
|
||||||
|
|
||||||
log("Updating sysctl_file: %s values: %s" % (sysctl_file, sysctl_dict_parsed),
|
|
||||||
level=DEBUG)
|
|
||||||
|
|
||||||
check_call(["sysctl", "-p", sysctl_file])
|
|
@ -1,93 +0,0 @@
|
|||||||
# Copyright 2014-2015 Canonical Limited.
|
|
||||||
#
|
|
||||||
# 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.
|
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
|
||||||
|
|
||||||
from charmhelpers.core import host
|
|
||||||
from charmhelpers.core import hookenv
|
|
||||||
|
|
||||||
|
|
||||||
def render(source, target, context, owner='root', group='root',
|
|
||||||
perms=0o444, templates_dir=None, encoding='UTF-8',
|
|
||||||
template_loader=None, config_template=None):
|
|
||||||
"""
|
|
||||||
Render a template.
|
|
||||||
|
|
||||||
The `source` path, if not absolute, is relative to the `templates_dir`.
|
|
||||||
|
|
||||||
The `target` path should be absolute. It can also be `None`, in which
|
|
||||||
case no file will be written.
|
|
||||||
|
|
||||||
The context should be a dict containing the values to be replaced in the
|
|
||||||
template.
|
|
||||||
|
|
||||||
config_template may be provided to render from a provided template instead
|
|
||||||
of loading from a file.
|
|
||||||
|
|
||||||
The `owner`, `group`, and `perms` options will be passed to `write_file`.
|
|
||||||
|
|
||||||
If omitted, `templates_dir` defaults to the `templates` folder in the charm.
|
|
||||||
|
|
||||||
The rendered template will be written to the file as well as being returned
|
|
||||||
as a string.
|
|
||||||
|
|
||||||
Note: Using this requires python-jinja2 or python3-jinja2; if it is not
|
|
||||||
installed, calling this will attempt to use charmhelpers.fetch.apt_install
|
|
||||||
to install it.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
from jinja2 import FileSystemLoader, Environment, exceptions
|
|
||||||
except ImportError:
|
|
||||||
try:
|
|
||||||
from charmhelpers.fetch import apt_install
|
|
||||||
except ImportError:
|
|
||||||
hookenv.log('Could not import jinja2, and could not import '
|
|
||||||
'charmhelpers.fetch to install it',
|
|
||||||
level=hookenv.ERROR)
|
|
||||||
raise
|
|
||||||
if sys.version_info.major == 2:
|
|
||||||
apt_install('python-jinja2', fatal=True)
|
|
||||||
else:
|
|
||||||
apt_install('python3-jinja2', fatal=True)
|
|
||||||
from jinja2 import FileSystemLoader, Environment, exceptions
|
|
||||||
|
|
||||||
if template_loader:
|
|
||||||
template_env = Environment(loader=template_loader)
|
|
||||||
else:
|
|
||||||
if templates_dir is None:
|
|
||||||
templates_dir = os.path.join(hookenv.charm_dir(), 'templates')
|
|
||||||
template_env = Environment(loader=FileSystemLoader(templates_dir))
|
|
||||||
|
|
||||||
# load from a string if provided explicitly
|
|
||||||
if config_template is not None:
|
|
||||||
template = template_env.from_string(config_template)
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
source = source
|
|
||||||
template = template_env.get_template(source)
|
|
||||||
except exceptions.TemplateNotFound as e:
|
|
||||||
hookenv.log('Could not load template %s from %s.' %
|
|
||||||
(source, templates_dir),
|
|
||||||
level=hookenv.ERROR)
|
|
||||||
raise e
|
|
||||||
content = template.render(context)
|
|
||||||
if target is not None:
|
|
||||||
target_dir = os.path.dirname(target)
|
|
||||||
if not os.path.exists(target_dir):
|
|
||||||
# This is a terrible default directory permission, as the file
|
|
||||||
# or its siblings will often contain secrets.
|
|
||||||
host.mkdir(os.path.dirname(target), owner, group, perms=0o755)
|
|
||||||
host.write_file(target, content.encode(encoding), owner, group, perms)
|
|
||||||
return content
|
|
@ -1,525 +0,0 @@
|
|||||||
#!/usr/bin/env python
|
|
||||||
# -*- coding: utf-8 -*-
|
|
||||||
#
|
|
||||||
# Copyright 2014-2015 Canonical Limited.
|
|
||||||
#
|
|
||||||
# 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.
|
|
||||||
#
|
|
||||||
# Authors:
|
|
||||||
# Kapil Thangavelu <kapil.foss@gmail.com>
|
|
||||||
#
|
|
||||||
"""
|
|
||||||
Intro
|
|
||||||
-----
|
|
||||||
|
|
||||||
A simple way to store state in units. This provides a key value
|
|
||||||
storage with support for versioned, transactional operation,
|
|
||||||
and can calculate deltas from previous values to simplify unit logic
|
|
||||||
when processing changes.
|
|
||||||
|
|
||||||
|
|
||||||
Hook Integration
|
|
||||||
----------------
|
|
||||||
|
|
||||||
There are several extant frameworks for hook execution, including
|
|
||||||
|
|
||||||
- charmhelpers.core.hookenv.Hooks
|
|
||||||
- charmhelpers.core.services.ServiceManager
|
|
||||||
|
|
||||||
The storage classes are framework agnostic, one simple integration is
|
|
||||||
via the HookData contextmanager. It will record the current hook
|
|
||||||
execution environment (including relation data, config data, etc.),
|
|
||||||
setup a transaction and allow easy access to the changes from
|
|
||||||
previously seen values. One consequence of the integration is the
|
|
||||||
reservation of particular keys ('rels', 'unit', 'env', 'config',
|
|
||||||
'charm_revisions') for their respective values.
|
|
||||||
|
|
||||||
Here's a fully worked integration example using hookenv.Hooks::
|
|
||||||
|
|
||||||
from charmhelper.core import hookenv, unitdata
|
|
||||||
|
|
||||||
hook_data = unitdata.HookData()
|
|
||||||
db = unitdata.kv()
|
|
||||||
hooks = hookenv.Hooks()
|
|
||||||
|
|
||||||
@hooks.hook
|
|
||||||
def config_changed():
|
|
||||||
# Print all changes to configuration from previously seen
|
|
||||||
# values.
|
|
||||||
for changed, (prev, cur) in hook_data.conf.items():
|
|
||||||
print('config changed', changed,
|
|
||||||
'previous value', prev,
|
|
||||||
'current value', cur)
|
|
||||||
|
|
||||||
# Get some unit specific bookeeping
|
|
||||||
if not db.get('pkg_key'):
|
|
||||||
key = urllib.urlopen('https://example.com/pkg_key').read()
|
|
||||||
db.set('pkg_key', key)
|
|
||||||
|
|
||||||
# Directly access all charm config as a mapping.
|
|
||||||
conf = db.getrange('config', True)
|
|
||||||
|
|
||||||
# Directly access all relation data as a mapping
|
|
||||||
rels = db.getrange('rels', True)
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
with hook_data():
|
|
||||||
hook.execute()
|
|
||||||
|
|
||||||
|
|
||||||
A more basic integration is via the hook_scope context manager which simply
|
|
||||||
manages transaction scope (and records hook name, and timestamp)::
|
|
||||||
|
|
||||||
>>> from unitdata import kv
|
|
||||||
>>> db = kv()
|
|
||||||
>>> with db.hook_scope('install'):
|
|
||||||
... # do work, in transactional scope.
|
|
||||||
... db.set('x', 1)
|
|
||||||
>>> db.get('x')
|
|
||||||
1
|
|
||||||
|
|
||||||
|
|
||||||
Usage
|
|
||||||
-----
|
|
||||||
|
|
||||||
Values are automatically json de/serialized to preserve basic typing
|
|
||||||
and complex data struct capabilities (dicts, lists, ints, booleans, etc).
|
|
||||||
|
|
||||||
Individual values can be manipulated via get/set::
|
|
||||||
|
|
||||||
>>> kv.set('y', True)
|
|
||||||
>>> kv.get('y')
|
|
||||||
True
|
|
||||||
|
|
||||||
# We can set complex values (dicts, lists) as a single key.
|
|
||||||
>>> kv.set('config', {'a': 1, 'b': True'})
|
|
||||||
|
|
||||||
# Also supports returning dictionaries as a record which
|
|
||||||
# provides attribute access.
|
|
||||||
>>> config = kv.get('config', record=True)
|
|
||||||
>>> config.b
|
|
||||||
True
|
|
||||||
|
|
||||||
|
|
||||||
Groups of keys can be manipulated with update/getrange::
|
|
||||||
|
|
||||||
>>> kv.update({'z': 1, 'y': 2}, prefix="gui.")
|
|
||||||
>>> kv.getrange('gui.', strip=True)
|
|
||||||
{'z': 1, 'y': 2}
|
|
||||||
|
|
||||||
When updating values, its very helpful to understand which values
|
|
||||||
have actually changed and how have they changed. The storage
|
|
||||||
provides a delta method to provide for this::
|
|
||||||
|
|
||||||
>>> data = {'debug': True, 'option': 2}
|
|
||||||
>>> delta = kv.delta(data, 'config.')
|
|
||||||
>>> delta.debug.previous
|
|
||||||
None
|
|
||||||
>>> delta.debug.current
|
|
||||||
True
|
|
||||||
>>> delta
|
|
||||||
{'debug': (None, True), 'option': (None, 2)}
|
|
||||||
|
|
||||||
Note the delta method does not persist the actual change, it needs to
|
|
||||||
be explicitly saved via 'update' method::
|
|
||||||
|
|
||||||
>>> kv.update(data, 'config.')
|
|
||||||
|
|
||||||
Values modified in the context of a hook scope retain historical values
|
|
||||||
associated to the hookname.
|
|
||||||
|
|
||||||
>>> with db.hook_scope('config-changed'):
|
|
||||||
... db.set('x', 42)
|
|
||||||
>>> db.gethistory('x')
|
|
||||||
[(1, u'x', 1, u'install', u'2015-01-21T16:49:30.038372'),
|
|
||||||
(2, u'x', 42, u'config-changed', u'2015-01-21T16:49:30.038786')]
|
|
||||||
|
|
||||||
"""
|
|
||||||
|
|
||||||
import collections
|
|
||||||
import contextlib
|
|
||||||
import datetime
|
|
||||||
import itertools
|
|
||||||
import json
|
|
||||||
import os
|
|
||||||
import pprint
|
|
||||||
import sqlite3
|
|
||||||
import sys
|
|
||||||
|
|
||||||
__author__ = 'Kapil Thangavelu <kapil.foss@gmail.com>'
|
|
||||||
|
|
||||||
|
|
||||||
class Storage(object):
|
|
||||||
"""Simple key value database for local unit state within charms.
|
|
||||||
|
|
||||||
Modifications are not persisted unless :meth:`flush` is called.
|
|
||||||
|
|
||||||
To support dicts, lists, integer, floats, and booleans values
|
|
||||||
are automatically json encoded/decoded.
|
|
||||||
|
|
||||||
Note: to facilitate unit testing, ':memory:' can be passed as the
|
|
||||||
path parameter which causes sqlite3 to only build the db in memory.
|
|
||||||
This should only be used for testing purposes.
|
|
||||||
"""
|
|
||||||
def __init__(self, path=None):
|
|
||||||
self.db_path = path
|
|
||||||
if path is None:
|
|
||||||
if 'UNIT_STATE_DB' in os.environ:
|
|
||||||
self.db_path = os.environ['UNIT_STATE_DB']
|
|
||||||
else:
|
|
||||||
self.db_path = os.path.join(
|
|
||||||
os.environ.get('CHARM_DIR', ''), '.unit-state.db')
|
|
||||||
if self.db_path != ':memory:':
|
|
||||||
with open(self.db_path, 'a') as f:
|
|
||||||
os.fchmod(f.fileno(), 0o600)
|
|
||||||
self.conn = sqlite3.connect('%s' % self.db_path)
|
|
||||||
self.cursor = self.conn.cursor()
|
|
||||||
self.revision = None
|
|
||||||
self._closed = False
|
|
||||||
self._init()
|
|
||||||
|
|
||||||
def close(self):
|
|
||||||
if self._closed:
|
|
||||||
return
|
|
||||||
self.flush(False)
|
|
||||||
self.cursor.close()
|
|
||||||
self.conn.close()
|
|
||||||
self._closed = True
|
|
||||||
|
|
||||||
def get(self, key, default=None, record=False):
|
|
||||||
self.cursor.execute('select data from kv where key=?', [key])
|
|
||||||
result = self.cursor.fetchone()
|
|
||||||
if not result:
|
|
||||||
return default
|
|
||||||
if record:
|
|
||||||
return Record(json.loads(result[0]))
|
|
||||||
return json.loads(result[0])
|
|
||||||
|
|
||||||
def getrange(self, key_prefix, strip=False):
|
|
||||||
"""
|
|
||||||
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()
|
|
||||||
|
|
||||||
if not result:
|
|
||||||
return {}
|
|
||||||
if not strip:
|
|
||||||
key_prefix = ''
|
|
||||||
return dict([
|
|
||||||
(k[len(key_prefix):], json.loads(v)) for k, v in result])
|
|
||||||
|
|
||||||
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():
|
|
||||||
self.set("%s%s" % (prefix, k), v)
|
|
||||||
|
|
||||||
def unset(self, key):
|
|
||||||
"""
|
|
||||||
Remove a key from the database entirely.
|
|
||||||
"""
|
|
||||||
self.cursor.execute('delete from kv where key=?', [key])
|
|
||||||
if self.revision and self.cursor.rowcount:
|
|
||||||
self.cursor.execute(
|
|
||||||
'insert into kv_revisions values (?, ?, ?)',
|
|
||||||
[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):
|
|
||||||
"""
|
|
||||||
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)
|
|
||||||
|
|
||||||
self.cursor.execute('select data from kv where key=?', [key])
|
|
||||||
exists = self.cursor.fetchone()
|
|
||||||
|
|
||||||
# Skip mutations to the same value
|
|
||||||
if exists:
|
|
||||||
if exists[0] == serialized:
|
|
||||||
return value
|
|
||||||
|
|
||||||
if not exists:
|
|
||||||
self.cursor.execute(
|
|
||||||
'insert into kv (key, data) values (?, ?)',
|
|
||||||
(key, serialized))
|
|
||||||
else:
|
|
||||||
self.cursor.execute('''
|
|
||||||
update kv
|
|
||||||
set data = ?
|
|
||||||
where key = ?''', [serialized, key])
|
|
||||||
|
|
||||||
# Save
|
|
||||||
if not self.revision:
|
|
||||||
return value
|
|
||||||
|
|
||||||
self.cursor.execute(
|
|
||||||
'select 1 from kv_revisions where key=? and revision=?',
|
|
||||||
[key, self.revision])
|
|
||||||
exists = self.cursor.fetchone()
|
|
||||||
|
|
||||||
if not exists:
|
|
||||||
self.cursor.execute(
|
|
||||||
'''insert into kv_revisions (
|
|
||||||
revision, key, data) values (?, ?, ?)''',
|
|
||||||
(self.revision, key, serialized))
|
|
||||||
else:
|
|
||||||
self.cursor.execute(
|
|
||||||
'''
|
|
||||||
update kv_revisions
|
|
||||||
set data = ?
|
|
||||||
where key = ?
|
|
||||||
and revision = ?''',
|
|
||||||
[serialized, key, self.revision])
|
|
||||||
|
|
||||||
return value
|
|
||||||
|
|
||||||
def delta(self, mapping, prefix):
|
|
||||||
"""
|
|
||||||
return a delta containing values that have changed.
|
|
||||||
"""
|
|
||||||
previous = self.getrange(prefix, strip=True)
|
|
||||||
if not previous:
|
|
||||||
pk = set()
|
|
||||||
else:
|
|
||||||
pk = set(previous.keys())
|
|
||||||
ck = set(mapping.keys())
|
|
||||||
delta = DeltaSet()
|
|
||||||
|
|
||||||
# added
|
|
||||||
for k in ck.difference(pk):
|
|
||||||
delta[k] = Delta(None, mapping[k])
|
|
||||||
|
|
||||||
# removed
|
|
||||||
for k in pk.difference(ck):
|
|
||||||
delta[k] = Delta(previous[k], None)
|
|
||||||
|
|
||||||
# changed
|
|
||||||
for k in pk.intersection(ck):
|
|
||||||
c = mapping[k]
|
|
||||||
p = previous[k]
|
|
||||||
if c != p:
|
|
||||||
delta[k] = Delta(p, c)
|
|
||||||
|
|
||||||
return delta
|
|
||||||
|
|
||||||
@contextlib.contextmanager
|
|
||||||
def hook_scope(self, name=""):
|
|
||||||
"""Scope all future interactions to the current hook execution
|
|
||||||
revision."""
|
|
||||||
assert not self.revision
|
|
||||||
self.cursor.execute(
|
|
||||||
'insert into hooks (hook, date) values (?, ?)',
|
|
||||||
(name or sys.argv[0],
|
|
||||||
datetime.datetime.utcnow().isoformat()))
|
|
||||||
self.revision = self.cursor.lastrowid
|
|
||||||
try:
|
|
||||||
yield self.revision
|
|
||||||
self.revision = None
|
|
||||||
except Exception:
|
|
||||||
self.flush(False)
|
|
||||||
self.revision = None
|
|
||||||
raise
|
|
||||||
else:
|
|
||||||
self.flush()
|
|
||||||
|
|
||||||
def flush(self, save=True):
|
|
||||||
if save:
|
|
||||||
self.conn.commit()
|
|
||||||
elif self._closed:
|
|
||||||
return
|
|
||||||
else:
|
|
||||||
self.conn.rollback()
|
|
||||||
|
|
||||||
def _init(self):
|
|
||||||
self.cursor.execute('''
|
|
||||||
create table if not exists kv (
|
|
||||||
key text,
|
|
||||||
data text,
|
|
||||||
primary key (key)
|
|
||||||
)''')
|
|
||||||
self.cursor.execute('''
|
|
||||||
create table if not exists kv_revisions (
|
|
||||||
key text,
|
|
||||||
revision integer,
|
|
||||||
data text,
|
|
||||||
primary key (key, revision)
|
|
||||||
)''')
|
|
||||||
self.cursor.execute('''
|
|
||||||
create table if not exists hooks (
|
|
||||||
version integer primary key autoincrement,
|
|
||||||
hook text,
|
|
||||||
date text
|
|
||||||
)''')
|
|
||||||
self.conn.commit()
|
|
||||||
|
|
||||||
def gethistory(self, key, deserialize=False):
|
|
||||||
self.cursor.execute(
|
|
||||||
'''
|
|
||||||
select kv.revision, kv.key, kv.data, h.hook, h.date
|
|
||||||
from kv_revisions kv,
|
|
||||||
hooks h
|
|
||||||
where kv.key=?
|
|
||||||
and kv.revision = h.version
|
|
||||||
''', [key])
|
|
||||||
if deserialize is False:
|
|
||||||
return self.cursor.fetchall()
|
|
||||||
return map(_parse_history, self.cursor.fetchall())
|
|
||||||
|
|
||||||
def debug(self, fh=sys.stderr):
|
|
||||||
self.cursor.execute('select * from kv')
|
|
||||||
pprint.pprint(self.cursor.fetchall(), stream=fh)
|
|
||||||
self.cursor.execute('select * from kv_revisions')
|
|
||||||
pprint.pprint(self.cursor.fetchall(), stream=fh)
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_history(d):
|
|
||||||
return (d[0], d[1], json.loads(d[2]), d[3],
|
|
||||||
datetime.datetime.strptime(d[-1], "%Y-%m-%dT%H:%M:%S.%f"))
|
|
||||||
|
|
||||||
|
|
||||||
class HookData(object):
|
|
||||||
"""Simple integration for existing hook exec frameworks.
|
|
||||||
|
|
||||||
Records all unit information, and stores deltas for processing
|
|
||||||
by the hook.
|
|
||||||
|
|
||||||
Sample::
|
|
||||||
|
|
||||||
from charmhelper.core import hookenv, unitdata
|
|
||||||
|
|
||||||
changes = unitdata.HookData()
|
|
||||||
db = unitdata.kv()
|
|
||||||
hooks = hookenv.Hooks()
|
|
||||||
|
|
||||||
@hooks.hook
|
|
||||||
def config_changed():
|
|
||||||
# View all changes to configuration
|
|
||||||
for changed, (prev, cur) in changes.conf.items():
|
|
||||||
print('config changed', changed,
|
|
||||||
'previous value', prev,
|
|
||||||
'current value', cur)
|
|
||||||
|
|
||||||
# Get some unit specific bookeeping
|
|
||||||
if not db.get('pkg_key'):
|
|
||||||
key = urllib.urlopen('https://example.com/pkg_key').read()
|
|
||||||
db.set('pkg_key', key)
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
with changes():
|
|
||||||
hook.execute()
|
|
||||||
|
|
||||||
"""
|
|
||||||
def __init__(self):
|
|
||||||
self.kv = kv()
|
|
||||||
self.conf = None
|
|
||||||
self.rels = None
|
|
||||||
|
|
||||||
@contextlib.contextmanager
|
|
||||||
def __call__(self):
|
|
||||||
from charmhelpers.core import hookenv
|
|
||||||
hook_name = hookenv.hook_name()
|
|
||||||
|
|
||||||
with self.kv.hook_scope(hook_name):
|
|
||||||
self._record_charm_version(hookenv.charm_dir())
|
|
||||||
delta_config, delta_relation = self._record_hook(hookenv)
|
|
||||||
yield self.kv, delta_config, delta_relation
|
|
||||||
|
|
||||||
def _record_charm_version(self, charm_dir):
|
|
||||||
# Record revisions.. charm revisions are meaningless
|
|
||||||
# to charm authors as they don't control the revision.
|
|
||||||
# so logic dependnent on revision is not particularly
|
|
||||||
# useful, however it is useful for debugging analysis.
|
|
||||||
charm_rev = open(
|
|
||||||
os.path.join(charm_dir, 'revision')).read().strip()
|
|
||||||
charm_rev = charm_rev or '0'
|
|
||||||
revs = self.kv.get('charm_revisions', [])
|
|
||||||
if charm_rev not in revs:
|
|
||||||
revs.append(charm_rev.strip() or '0')
|
|
||||||
self.kv.set('charm_revisions', revs)
|
|
||||||
|
|
||||||
def _record_hook(self, hookenv):
|
|
||||||
data = hookenv.execution_environment()
|
|
||||||
self.conf = conf_delta = self.kv.delta(data['conf'], 'config')
|
|
||||||
self.rels = rels_delta = self.kv.delta(data['rels'], 'rels')
|
|
||||||
self.kv.set('env', dict(data['env']))
|
|
||||||
self.kv.set('unit', data['unit'])
|
|
||||||
self.kv.set('relid', data.get('relid'))
|
|
||||||
return conf_delta, rels_delta
|
|
||||||
|
|
||||||
|
|
||||||
class Record(dict):
|
|
||||||
|
|
||||||
__slots__ = ()
|
|
||||||
|
|
||||||
def __getattr__(self, k):
|
|
||||||
if k in self:
|
|
||||||
return self[k]
|
|
||||||
raise AttributeError(k)
|
|
||||||
|
|
||||||
|
|
||||||
class DeltaSet(Record):
|
|
||||||
|
|
||||||
__slots__ = ()
|
|
||||||
|
|
||||||
|
|
||||||
Delta = collections.namedtuple('Delta', ['previous', 'current'])
|
|
||||||
|
|
||||||
|
|
||||||
_KV = None
|
|
||||||
|
|
||||||
|
|
||||||
def kv():
|
|
||||||
global _KV
|
|
||||||
if _KV is None:
|
|
||||||
_KV = Storage()
|
|
||||||
return _KV
|
|
@ -1,25 +0,0 @@
|
|||||||
import platform
|
|
||||||
|
|
||||||
|
|
||||||
def get_platform():
|
|
||||||
"""Return the current OS platform.
|
|
||||||
|
|
||||||
For example: if current os platform is Ubuntu then a string "ubuntu"
|
|
||||||
will be returned (which is the name of the module).
|
|
||||||
This string is used to decide which platform module should be imported.
|
|
||||||
"""
|
|
||||||
# linux_distribution is deprecated and will be removed in Python 3.7
|
|
||||||
# Warings *not* disabled, as we certainly need to fix this.
|
|
||||||
tuple_platform = platform.linux_distribution()
|
|
||||||
current_platform = tuple_platform[0]
|
|
||||||
if "Ubuntu" in current_platform:
|
|
||||||
return "ubuntu"
|
|
||||||
elif "CentOS" in current_platform:
|
|
||||||
return "centos"
|
|
||||||
elif "debian" in current_platform:
|
|
||||||
# Stock Python does not detect Ubuntu and instead returns debian.
|
|
||||||
# Or at least it does in some build environments like Travis CI
|
|
||||||
return "ubuntu"
|
|
||||||
else:
|
|
||||||
raise RuntimeError("This module is not supported on {}."
|
|
||||||
.format(current_platform))
|
|
Loading…
Reference in New Issue
Block a user