charms.openstack/charms_openstack/plugins/classes.py

541 lines
21 KiB
Python

# Copyright 2019 Canonical Ltd
#
# 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 collections
import enum
import os
import shutil
import socket
import subprocess
import charms_openstack.charm
from charms_openstack.charm.classes import SNAP_PATH_PREFIX_FORMAT
import charmhelpers.core as ch_core
import charmhelpers.contrib.openstack.context as ch_context
import charmhelpers.contrib.openstack.policyd as ch_policyd
class BaseOpenStackCephCharm(object):
"""Base class for Ceph classes.
Provided as a mixin so charm authors can compose the charm class
appropriate for their use case.
"""
# Ceph cluster name is used for naming of various configuration files and
# directories. It is also used by Ceph command line tools to interface
# with multiple distinct Ceph clusters from one place.
ceph_cluster_name = 'ceph'
# Both consumers and providers of Ceph services share a pattern of the
# need for a key and a keyring file on disk, they also share naming
# conventions.
# The most used key naming convention is for all instances of a service
# to share a key named after the service.
# Some services follow a different pattern with unique key names for each
# instance of a service. (e.g. RadosGW Multi-Site, RBD Mirroring)
ceph_key_per_unit_name = False
# Ceph service name and service type is used for sectioning of
# ``ceph.conf`, appropriate naming of keys and keyring files. By default
# ceph service name is determined from `application_name` property.
# If this does not fit your use case you can override.
ceph_service_name_override = ''
# Unless you are writing a charm providing Ceph mon|osd|mgr|mds services
# this should probably be left as-is.
class CephServiceType(enum.Enum):
"""Ceph service type."""
client = 'client'
mds = 'mds'
mgr = 'mgr'
mon = 'mon'
osd = 'osd'
def __str__(self):
"""Return string representation of value.
:returns: string representation of value.
:rtype: str
"""
return self.value
ceph_service_type = CephServiceType.client
# Path prefix to where the Ceph keyring should be stored.
ceph_keyring_path_prefix = '/etc/ceph'
@property
@ch_core.hookenv.cached
def application_name(self):
"""Provide the name this instance of the charm has in the Juju model.
:returns: Application name
:rtype: str
"""
return ch_core.hookenv.application_name()
@property
def snap_path_prefix(self, snap=None):
"""Provide the path prefix for a snap.
:param snap: (Optional) The snap you want to build a path prefix for
If not provided will attempt to build for the first snap
listed in self.snaps.
:type snap: str
:returns: Path prefix for snap or the empty string ('')
:rtype: str
"""
if snap:
return SNAP_PATH_PREFIX_FORMAT.format(snap)
elif self.snaps:
return SNAP_PATH_PREFIX_FORMAT.format(self.snaps[0])
else:
return ''
@property
def ceph_service_name(self):
"""Provide Ceph service name for use in config, key and keyrings.
:returns: Ceph service name
:rtype: str
"""
return (self.ceph_service_name_override or
self.application_name)
@property
def ceph_key_name(self):
"""Provide Ceph key name for the charm managed service.
:returns: Ceph key name
:rtype: str
"""
if self.ceph_service_type == self.CephServiceType.client:
base_key_name = '{}.{}'.format(
self.ceph_service_type,
self.ceph_service_name)
else:
base_key_name = self.ceph_service_name
if self.ceph_key_per_unit_name:
return '{}.{}'.format(
base_key_name,
socket.gethostname())
else:
return base_key_name
@property
def ceph_keyring_path(self):
"""Provide a path to where the Ceph keyring should be stored.
:returns: Path to directory
:rtype: str
"""
return os.path.join(self.snap_path_prefix,
self.ceph_keyring_path_prefix)
def ceph_keyring_absolute_path(self, cluster_name=None):
"""Provide absolute path to keyring file.
:param cluster_name: (Optional) Name of Ceph cluster to operate on.
Defaults to value of ``self.ceph_cluster_name``.
:type cluster_name: str
:returns: Absolute path to keyring file
:rtype: str
"""
if self.ceph_service_type == self.CephServiceType.client:
keyring_name = ('{}.{}.keyring'
.format(cluster_name or self.ceph_cluster_name,
self.ceph_key_name))
else:
keyring_name = 'keyring'
keyring_absolute_path = os.path.join(self.ceph_keyring_path,
keyring_name)
return keyring_absolute_path
def configure_ceph_keyring(self, key, cluster_name=None):
"""Creates or updates a Ceph keyring file.
:param key: Key data
:type key: str
:param cluster_name: (Optional) Name of Ceph cluster to operate on.
Defaults to value of ``self.ceph_cluster_name``.
:type cluster_name: str
:returns: Absolute path to keyring file
:rtype: str
:raises: subprocess.CalledProcessError, OSError
"""
if not os.path.isdir(self.ceph_keyring_path):
ch_core.host.mkdir(self.ceph_keyring_path,
owner=self.user, group=self.group, perms=0o750)
keyring_absolute_path = self.ceph_keyring_absolute_path(
cluster_name=cluster_name)
cmd = [
'ceph-authtool', keyring_absolute_path,
'--create-keyring', '--name={}'.format(self.ceph_key_name),
'--add-key', key, '--mode', '0600',
]
try:
subprocess.check_call(cmd)
except subprocess.CalledProcessError as cp:
if not cp.returncode == 1:
raise
# the version of ceph-authtool on the system does not have
# --mode command line argument
subprocess.check_call(cmd[:-2])
os.chmod(keyring_absolute_path, 0o600)
shutil.chown(keyring_absolute_path, user=self.user, group=self.group)
return keyring_absolute_path
def delete_ceph_keyring(self, cluster_name=None):
"""Deletes an existing Ceph keyring file.
:param cluster_name: (Optional) Name of Ceph cluster to operate on.
Defaults to value of ``self.ceph_cluster_name``.
:type cluster_name: str
:returns: Absolute path to the now removed keyring file or empty string
:rtype: str
"""
keyring_absolute_path = self.ceph_keyring_absolute_path(
cluster_name=cluster_name)
try:
os.remove(keyring_absolute_path)
return keyring_absolute_path
except OSError:
return ''
@staticmethod
def _get_bluestore_compression():
"""Get BlueStore Compression charm configuration if present.
:returns: Dictionary of options suitable for passing on as keyword
arguments or None.
:rtype: Optional[Dict[str,any]]
:raises: ValueError
"""
try:
bluestore_compression = (
ch_context.CephBlueStoreCompressionContext())
bluestore_compression.validate()
except KeyError:
# The charm does not have BlueStore Compression options defined
bluestore_compression = None
if bluestore_compression:
return bluestore_compression.get_kwargs()
def states_to_check(self, required_relations=None):
"""Augment states to check handling.
Validates Ceph specific configuration options and adds end user
feedback through juju status.
:param required_relations: List of relations which overrides
self.relations
:type required_relations: Optional[List[str]]
:returns: Map of relations and their states to check
:rtype: Dict[str,List[Tuple[str,str,str]]]
"""
states_to_check = super().states_to_check(
required_relations=required_relations)
try:
self._get_bluestore_compression()
except ValueError as e:
# we add a made up relation to have the library set the status for
# us.
states_to_check['charm.bluestore_compression'] = [
('charm.bluestore_compression',
'blocked',
'Invalid configuration: {}'.format(str(e))),
]
return states_to_check
def create_pool(self, ceph_interface, pool_name=None):
"""Request pool for service.
The pool created will be a replicated pool and will adopt standard
BlueStore compression options that are configured for the charm.
Charms that do not have provide BlueStore compression options will
create pools using the Ceph storage configured defaults.
:param ceph_interface: Ceph interface instance
:type ceph_interface: CephRequires
:param pool_name: (Optional) name of the pool to create.
Defaults to the name of the application deployed.
:type pool_name: str
"""
try:
bluestore_compression = self._get_bluestore_compression()
except ValueError as e:
# One or more of the values provided for the configuration options
# is invalid, do not attempt to create pool. The end user will be
# informed about the condition through juju status
# (see the ``states_to_check`` method above).
ch_core.hookenv.log('Invalid value(s) provided for Ceph BlueStore '
'compression: "{}"'
.format(str(e)))
return
kwargs = {
'name': pool_name or self.application_name,
}
if bluestore_compression:
kwargs.update(bluestore_compression)
ceph_interface.create_replicated_pool(**kwargs)
class CephCharm(BaseOpenStackCephCharm,
charms_openstack.charm.OpenStackCharm):
"""Class for charms deploying Ceph services.
It provides useful defaults to make release detection work when no
OpenStack packages are installed.
Ceph services also have different preferences for placement of keyring
files.
Code useful for and shared among charms deploying software that want to
consume Ceph services should be added to the BaseOpenStackCephCharm base
class.
"""
abstract_class = True
# Ubuntu Ceph packages are distributed along with the Ubuntu OpenStack
# packages, both for distro and UCA.
# Map OpenStack release to the Ceph release distributed with it.
package_codenames = {
'ceph-common': collections.OrderedDict([
('0', 'icehouse'), # 0.80 Firefly
('10', 'mitaka'), # 10.2.x Jewel
('12', 'pike'), # 12.2.x Luminous
('13', 'rocky'), # 13.2.x Mimic
('14', 'train'), # 14.2.x Nautilus
('15', 'ussuri'), # 15.2.x Octopus
]),
}
# Package to determine application version from
version_package = release_pkg = 'ceph-common'
# release = the first release in which this charm works. Refer to
# package_codenames variable above for table of OpenStack to Ceph releases.
release = 'icehouse'
# Python version used to execute installed workload
python_version = 3
# The name of the repository source configuration option.
# The ``ceph`` layer provides the ``config.yaml`` counterpart.
source_config_key = 'source'
# To make use of the CephRelationAdapter the derived charm class should
# define its own RelationAdapters class that inherits from
# ``adapters.OpenStackRelationAdapters`` or
# ``adapters.OpenStackAPIRelationAdapters``, whichever is most relevant.
#
# The custom RelationAdapters class should map the relation that provides
# the interface with a``mon_hosts`` property or function to the
# CephRelationAdapter by extending the ``relation_adapters`` dict.
#
# There is currently no standardization of relevant relation names among
# the Ceph providing or consuming charms, so it does currently not make
# sense to add this to the default relation adapters.
# adapters_class = MyCephCharmRelationAdapters
# Path prefix to where the Ceph keyring should be stored.
ceph_keyring_path_prefix = '/var/lib/ceph'
def __init__(self, **kwargs):
"""Initialize class."""
super().__init__(**kwargs)
self.hostname = socket.gethostname()
@property
def ceph_keyring_path(self):
"""Provide a path to where the Ceph keyring should be stored.
:returns: Path to directory
:rtype: str
"""
keyring_path_components = [
self.snap_path_prefix,
self.ceph_keyring_path_prefix,
self.ceph_service_name]
if self.ceph_service_type != self.CephServiceType.client:
keyring_path_components.append(
'{}-{}'.format(self.ceph_cluster_name,
self.hostname))
return os.path.join(*keyring_path_components)
def configure_ceph_keyring(self, key, cluster_name=None):
"""Override parent method for Ceph service providing charms.
:param cluster_name: (Optional) Name of Ceph cluster to operate on.
Defaults to value of ``self.ceph_cluster_name``.
:type cluster_name: str
:raises: OSError
"""
keyring_absolute_path = super().configure_ceph_keyring(
key, cluster_name=cluster_name)
if self.ceph_service_type != self.CephServiceType.client:
return
# If the service is a client-type sevice (sych as RBD Mirror) add
# symlink to key in ``/etc/ceph``.
symlink_absolute_path = os.path.join(
'/etc/ceph',
os.path.basename(keyring_absolute_path))
if os.path.exists(symlink_absolute_path):
try:
if (os.readlink(symlink_absolute_path) !=
keyring_absolute_path):
os.remove(symlink_absolute_path)
else:
# Symlink exists and points to expected location
return
except OSError:
# We expected a symlink.
# Fall through and let os.symlink raise error.
pass
os.symlink(keyring_absolute_path, symlink_absolute_path)
def install(self):
"""Install packages related to this charm based on
contents of self.packages attribute, after first
configuring the installation source.
"""
self.configure_source()
super().install()
class PolicydOverridePlugin(object):
"""The PolicydOverridePlugin is provided to manage the policy.d overrides
to charms.openstack charms. It heavily leans on the
charmhelpers.contrib.openstack.policyd to provide the functionality. The
methods provided in this class simply use the functions from charm-helpers
so that charm authors can simply include this plugin class into the
inheritance list of the charm class.
It's very important that the PolicyOverridePlugin class appear FIRST in the
list of classes when declaring the charm class. This is to ensure that the
config_changed() method in this class gets called first, and it then calls
other classes. Otherwise, the config_changed method in the base class will
need to call the config_changed() method in this class manually. e.g. from
Designate:
class DesignateCharm(ch_plugins.PolicydOverridePlugin,
openstack_charm.HAOpenStackCharm):
Note that this feature is only available with OpenStack versions of
'queens' and later, and Ubuntu versions of 'bionic' and later. Prior to
those versions, the feature will not activate. This is checked in the
charm-helpers policyd implementation functions which are called from this
class' implementation.
This should be read in conjunction with the module
charmhelpers.contrib.openstack.policyd which provides further details on
the changes that need to be made to a charm to enable this feature.
Note that the metadata.yaml and config.yaml needs to be updated for the
charm to actually be able to use this class. See the
charmhelpers.contrib.openstack.policyd module for further details.
The following class variables are used to drive the plugin and should be
declared on the class:
policyd_service_name = str
policyd_blacklist_paths = Union[None, List[str]]
policyd_blacklist_keys = Union[None, List[str]]
policyd_template_function = Union[None, Callable[[str], str]]
policyd_restart_on_change = Union[None, bool]
These have the following meanings:
policyd_service_name:
This is the name of the payload that is having an override. e.g.
keystone. It is used to construct the policy.d directory:
/etc/keystone/policy.d/
policyd_blacklist_paths: (Optional)
These are other policyd overrides that exist in the above directory
that should not be touched. It is a list of the FULL path. e.g.
/etc/keystone/policy.d/charm-overrides.yaml
policyd_blacklist_keys: (Optional)
These are keys that should not appear in the YAML files. e.g. admin.
policyd_template_function: (Optional)
This is an callable that takes a string that returns another string
that tis then loaded as the yaml file. This is intended to allow a
charm to modify the proposed yaml file to allow substitution of rules
and values under the control of the charm. The charm needs to supply
the substitution function (and thus the variables that will be used).
policyd_restart_on_change: Optional
If set to True, then the service will be restarted using the charm
class' `restart_services` method.
"""
def _policyd_function_args(self):
"""Returns the parameters that need to be passed to the charm-helpers
policyd implemenation functions.
:returns: ([openstack_release, payload_name],
{blacklist_paths=...,
blacklist_keys=...,
template_function=...,
restart_handler=...,})
:rtype: Tuple[List[str,str], Dict[str,str]]
"""
blacklist_paths = getattr(self, 'policyd_blacklist_paths', None)
blacklist_keys = getattr(self, 'policyd_blacklist_keys', None)
template_function = getattr(self, 'policyd_template_function', None)
if getattr(self, 'policyd_restart_on_change', False):
restart_handler = self.restart_services
else:
restart_handler = None
return ([self.release, self.policyd_service_name],
dict(blacklist_paths=blacklist_paths,
blacklist_keys=blacklist_keys,
template_function=template_function,
restart_handler=restart_handler))
def _maybe_policyd_overrides(self):
args, kwargs = self._policyd_function_args()
ch_policyd.maybe_do_policyd_overrides(*args, **kwargs)
def install(self):
"""Hook into the install"""
super().install()
self._maybe_policyd_overrides()
def upgrade_charm(self):
"""Check the policyd during an upgrade_charm"""
super().upgrade_charm()
self._maybe_policyd_overrides()
def config_changed(self):
"""Note that this is usually a nop, and is only called from the default
handler. Please check that the charm implementation actually uses it.
"""
try:
super().config_changed()
except Exception:
pass
args, kwargs = self._policyd_function_args()
ch_policyd.maybe_do_policyd_overrides_on_config_changed(
*args, **kwargs)