diff --git a/charms_openstack/charm/__init__.py b/charms_openstack/charm/__init__.py new file mode 100644 index 0000000..8b802d5 --- /dev/null +++ b/charms_openstack/charm/__init__.py @@ -0,0 +1,41 @@ +# Copyright 2016 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. + +# OpenStackCharm() - base class for build OpenStack charms from for the +# reactive framework. + +# Pull in helpers that 'charms_openstack.charm' will export +from charms_openstack.charm.defaults import use_defaults +from charms_openstack.charm.core import ( + optional_interfaces, + provide_charm_instance, + get_charm_instance, + register_os_release_selector, +) +from charms_openstack.charm.classes import ( + OpenStackCharm, + OpenStackAPICharm, + HAOpenStackCharm, +) + +__all__ = ( + "OpenStackCharm", + "OpenStackAPICharm", + "HAOpenStackCharm", + "optional_interfaces", + "provide_charm_instance", + "get_charm_instance", + "register_os_release_selector", + "use_defaults", +) diff --git a/charms_openstack/charm/classes.py b/charms_openstack/charm/classes.py new file mode 100644 index 0000000..2c410f2 --- /dev/null +++ b/charms_openstack/charm/classes.py @@ -0,0 +1,558 @@ +import base64 +import contextlib +import os +import random +import string +import subprocess + +import charmhelpers.contrib.network.ip as ch_ip +import charmhelpers.contrib.openstack.utils as os_utils +import charmhelpers.core.hookenv as hookenv +import charmhelpers.core.host as ch_host +import charmhelpers.fetch as fetch +import charms.reactive as reactive + +from charms_openstack.charm.core import ( + BaseOpenStackCharm, + BaseOpenStackCharmActions, + BaseOpenStackCharmAssessStatus, +) +from charms_openstack.charm.utils import get_upstream_version +import charms_openstack.adapters as os_adapters +import charms_openstack.ip as os_ip + +VIP_KEY = "vip" +CIDR_KEY = "vip_cidr" +IFACE_KEY = "vip_iface" +APACHE_SSL_VHOST = '/etc/apache2/sites-available/openstack_https_frontend.conf' + + +class OpenStackCharm(BaseOpenStackCharm, + BaseOpenStackCharmActions, + BaseOpenStackCharmAssessStatus): + """ + Base class for all OpenStack Charm classes; + encapulates general OpenStack charm payload operations + + Theory: + Derive form this class, set the name, first_release and releases class + variables so that get_charm_instance() will create an instance of this + charm. + + See the other class variables for details on what they are for and do. + """ + + abstract_class = True + + # first_release = this is the first release in which this charm works + release = 'icehouse' + + # The name of the charm (for printing, etc.) + name = 'charmname' + + # List of packages to install + packages = [] + + # Package to determine application version from + # defaults to first in packages if not provided + version_package = None + + # Keystone endpoint type + service_type = None + + # Default service for the charm + default_service = None + + # A dictionary of: + # { + # 'config.file': ['list', 'of', 'services', 'to', 'restart'], + # 'config2.file': ['more', 'services'], + # } + restart_map = {} + + # The list of required services that are checked for assess_status + # e.g. required_relations = ['identity-service', 'shared-db'] + required_relations = [] + + # The command used to sync the database + sync_cmd = [] + + # The list of services that this charm manages + services = [] + + ha_resources = [] + HAPROXY_CONF = '/etc/haproxy/haproxy.cfg' + MEMCACHE_CONF = '/etc/memcached.conf' + # package_codenames = {} + + @property + def region(self): + """Return the OpenStack Region as contained in the config item 'region' + """ + return self.config['region'] + + @property + def public_url(self): + """Return the public endpoint URL for the default service as specified + in the self.default_service attribute + """ + return "{}:{}".format(os_ip.canonical_url(os_ip.PUBLIC), + self.api_port(self.default_service, + os_ip.PUBLIC)) + + @property + def admin_url(self): + """Return the admin endpoint URL for the default service as specificed + in the self.default_service attribute + """ + return "{}:{}".format(os_ip.canonical_url(os_ip.ADMIN), + self.api_port(self.default_service, + os_ip.ADMIN)) + + @property + def internal_url(self): + """Return the internal internal endpoint URL for the default service as + specificated in the self.default_service attribtue + """ + return "{}:{}".format(os_ip.canonical_url(os_ip.INTERNAL), + self.api_port(self.default_service, + os_ip.INTERNAL)) + + @property + def application_version(self): + """Return the current version of the application being deployed by + the charm, as indicated by the version_package attribute + """ + if not self.version_package: + self.version_package = self.packages[0] + version = get_upstream_version( + self.version_package + ) + if not version: + version = os_utils.os_release(self.version_package) + return version + + +class OpenStackAPICharm(OpenStackCharm): + """The base class for API OS charms -- this just bakes in the default + configuration and adapter classes. + """ + abstract_class = True + + # The adapters class that this charm uses to adapt interfaces. + # If None, then it defaults to OpenstackRelationAdapters + adapters_class = os_adapters.OpenStackAPIRelationAdapters + + # The configuration base class to use for the charm + # If None, then the default ConfigurationAdapter is used. + configuration_class = os_adapters.APIConfigurationAdapter + + def upgrade_charm(self): + """Setup token cache in case previous charm version did not.""" + self.setup_token_cache() + super().upgrade_charm() + + def install(self): + """Install packages related to this charm based on + contents of self.packages attribute. + """ + self.configure_source() + super().install() + + def setup_token_cache(self): + """Check if a token cache package is needed and install it if it is""" + if fetch.filter_installed_packages(self.token_cache_pkgs()): + self.install() + + def enable_memcache(self, release=None): + """Determine if memcache should be enabled on the local unit + + @param release: release of OpenStack currently deployed + @returns boolean Whether memcache should be enabled + """ + if not release: + release = os_utils.get_os_codename_install_source( + self.config['openstack-origin']) + if release not in os_utils.OPENSTACK_RELEASES: + return ValueError("Unkown release {}".format(release)) + return (os_utils.OPENSTACK_RELEASES.index(release) >= + os_utils.OPENSTACK_RELEASES.index('mitaka')) + + def token_cache_pkgs(self, release=None): + """Determine additional packages needed for token caching + + @param release: release of OpenStack currently deployed + @returns List of package to enable token caching + """ + packages = [] + if self.enable_memcache(release=release): + packages.extend(['memcached', 'python-memcache']) + return packages + + def get_amqp_credentials(self): + """Provide the default amqp username and vhost as a tuple. + + This needs to be overriden in a derived class to provide the username + and vhost to the amqp interface IF the default amqp handlers are being + used. + :returns (username, host): two strings to send to the amqp provider. + """ + raise RuntimeError( + "get_amqp_credentials() needs to be overriden in the derived " + "class") + + def get_database_setup(self): + """Provide the default database credentials as a list of 3-tuples + + This is used when using the default handlers for the shared-db service + and provides the (db, db_user, ip) for each database as a list. + + returns a structure of: + [ + {'database': , + 'username': , + 'hostname': + 'prefix': , }, + ] + + This allows multiple databases to be setup. + + If more complex database setup is required, then the default + setup_database() will need to be ignored, and a custom function + written. + + :returns [{'database': ...}, ...]: credentials for multiple databases + """ + raise RuntimeError( + "get_database_setup() needs to be overriden in the derived " + "class") + + @property + def all_packages(self): + """List of packages to be installed + + @return ['pkg1', 'pkg2', ...] + """ + return (super(OpenStackAPICharm, self).all_packages + + self.token_cache_pkgs()) + + @property + def full_restart_map(self): + """Map of services to be restarted if a file changes + + @return { + 'file1': ['svc1', 'svc3'], + 'file2': ['svc2', 'svc3'], + ... + } + """ + _restart_map = super(OpenStackAPICharm, self).full_restart_map.copy() + if self.enable_memcache(): + _restart_map[self.MEMCACHE_CONF] = ['memcached'] + return _restart_map + + +class HAOpenStackCharm(OpenStackAPICharm): + + abstract_class = True + + def __init__(self, **kwargs): + super(HAOpenStackCharm, self).__init__(**kwargs) + self.set_haproxy_stat_password() + + @property + def apache_vhost_file(self): + """Apache vhost for SSL termination + + :returns: string + """ + return APACHE_SSL_VHOST + + def enable_apache_ssl_vhost(self): + """Enable Apache vhost for SSL termination + + Enable Apache vhost for SSL termination if vhost exists and it is not + curently enabled + """ + if os.path.exists(self.apache_vhost_file): + check_enabled = subprocess.call( + ['a2query', '-s', 'openstack_https_frontend']) + if check_enabled != 0: + subprocess.check_call(['a2ensite', 'openstack_https_frontend']) + ch_host.service_reload('apache2', restart_on_failure=True) + + def configure_apache(self): + if self.apache_enabled(): + self.install() + self.enable_apache_modules() + self.enable_apache_ssl_vhost() + + @property + def all_packages(self): + """List of packages to be installed + + @return ['pkg1', 'pkg2', ...] + """ + _packages = super(HAOpenStackCharm, self).all_packages + if self.haproxy_enabled(): + _packages.append('haproxy') + if self.apache_enabled(): + _packages.append('apache2') + return _packages + + @property + def full_restart_map(self): + """Map of services to be restarted if a file changes + + @return { + 'file1': ['svc1', 'svc3'], + 'file2': ['svc2', 'svc3'], + ... + } + """ + _restart_map = super(HAOpenStackCharm, self).full_restart_map + if self.haproxy_enabled(): + _restart_map[self.HAPROXY_CONF] = ['haproxy'] + if self.apache_enabled(): + _restart_map[self.apache_vhost_file] = ['apache2'] + return _restart_map + + def apache_enabled(self): + """Determine if apache is being used + + @return True if apache is being used""" + return self.get_state('ssl.enabled') + + def haproxy_enabled(self): + """Determine if haproxy is fronting the services + + @return True if haproxy is fronting the service""" + return 'haproxy' in self.ha_resources + + def configure_ha_resources(self, hacluster): + """Inform the ha subordinate about each service it should manage. The + child class specifies the services via self.ha_resources + + @param hacluster instance of interface class HAClusterRequires + """ + RESOURCE_TYPES = { + 'vips': self._add_ha_vips_config, + 'haproxy': self._add_ha_haproxy_config, + } + if self.ha_resources: + for res_type in self.ha_resources: + RESOURCE_TYPES[res_type](hacluster) + hacluster.bind_resources(iface=self.config[IFACE_KEY]) + + def _add_ha_vips_config(self, hacluster): + """Add a VirtualIP object for each user specified vip to self.resources + + @param hacluster instance of interface class HAClusterRequires + """ + for vip in self.config.get(VIP_KEY, '').split(): + iface = (ch_ip.get_iface_for_address(vip) or + self.config.get(IFACE_KEY)) + netmask = (ch_ip.get_netmask_for_address(vip) or + self.config.get(CIDR_KEY)) + if iface is not None: + hacluster.add_vip(self.name, vip, iface, netmask) + + def _add_ha_haproxy_config(self, hacluster): + """Add a InitService object for haproxy to self.resources + + @param hacluster instance of interface class HAClusterRequires + """ + hacluster.add_init_service(self.name, 'haproxy') + + def set_haproxy_stat_password(self): + """Set a stats password for accessing haproxy statistics""" + if not self.get_state('haproxy.stat.password'): + password = ''.join([ + random.choice(string.ascii_letters + string.digits) + for n in range(32)]) + self.set_state('haproxy.stat.password', password) + + def enable_apache_modules(self): + """Enable Apache modules needed for SSL termination""" + restart = False + for module in ['ssl', 'proxy', 'proxy_http']: + check_enabled = subprocess.call(['a2query', '-m', module]) + if check_enabled != 0: + subprocess.check_call(['a2enmod', module]) + restart = True + if restart: + ch_host.service_restart('apache2') + + def configure_cert(self, cert, key, cn=None): + """Configure service SSL cert and key + + Write out service SSL certificate and key for Apache. + + @param cert string SSL Certificate + @param key string SSL Key + @param cn string Canonical name for service + """ + if not cn: + cn = os_ip.resolve_address(endpoint_type=os_ip.INTERNAL) + ssl_dir = os.path.join('/etc/apache2/ssl/', self.name) + ch_host.mkdir(path=ssl_dir) + if cn: + cert_filename = 'cert_{}'.format(cn) + key_filename = 'key_{}'.format(cn) + else: + cert_filename = 'cert' + key_filename = 'key' + + ch_host.write_file(path=os.path.join(ssl_dir, cert_filename), + content=cert.encode('utf-8')) + ch_host.write_file(path=os.path.join(ssl_dir, key_filename), + content=key.encode('utf-8')) + + def get_local_addresses(self): + """Return list of local addresses on each configured network + + For each network return an address the local unit has on that network + if one exists. + + @returns [private_addr, admin_addr, public_addr, ...] + """ + addresses = [ + os_utils.get_host_ip(hookenv.unit_get('private-address'))] + for addr_type in os_ip.ADDRESS_MAP.keys(): + laddr = os_ip.resolve_address(endpoint_type=addr_type) + if laddr: + addresses.append(laddr) + return sorted(list(set(addresses))) + + def get_certs_and_keys(self, keystone_interface=None): + """Collect SSL config for local endpoints + + SSL keys and certs may come from user specified configuration for this + charm or they may come directly from Keystone. + + If collecting from keystone there may be a certificate and key per + endpoint (public, admin etc). + + @returns [ + {'key': 'key1', 'cert': 'cert1', 'ca': 'ca1', 'cn': 'cn1'} + {'key': 'key2', 'cert': 'cert2', 'ca': 'ca2', 'cn': 'cn2'} + ... + ] + """ + if self.config_defined_ssl_key and self.config_defined_ssl_cert: + return [{ + 'key': self.config_defined_ssl_key.decode('utf-8'), + 'cert': self.config_defined_ssl_cert.decode('utf-8'), + 'ca': self.config_defined_ssl_ca.decode('utf-8'), + 'cn': None}] + elif keystone_interface: + keys_and_certs = [] + for addr in self.get_local_addresses(): + key = keystone_interface.get_ssl_key(addr) + cert = keystone_interface.get_ssl_cert(addr) + ca = keystone_interface.get_ssl_ca() + if key and cert: + keys_and_certs.append({ + 'key': key, + 'cert': cert, + 'ca': ca, + 'cn': addr}) + return keys_and_certs + else: + return [] + + def _get_b64decode_for(self, param): + config_value = self.config.get(param) + if config_value: + return base64.b64decode(config_value) + return None + + @property + @hookenv.cached + def config_defined_ssl_key(self): + return self._get_b64decode_for('ssl_key') + + @property + @hookenv.cached + def config_defined_ssl_cert(self): + return self._get_b64decode_for('ssl_cert') + + @property + @hookenv.cached + def config_defined_ssl_ca(self): + return self._get_b64decode_for('ssl_ca') + + @property + def rabbit_client_cert_dir(self): + return '/var/lib/charm/{}'.format(hookenv.service_name()) + + @property + def rabbit_cert_file(self): + return '{}/rabbit-client-ca.pem'.format(self.rabbit_client_cert_dir) + + def configure_ssl(self, keystone_interface=None): + """Configure SSL certificates and keys + + @param keystone_interface KeystoneRequires class + """ + keystone_interface = (reactive.RelationBase.from_state( + 'identity-service.available.ssl') or + reactive.RelationBase.from_state( + 'identity-service.available.ssl_legacy')) + ssl_objects = self.get_certs_and_keys( + keystone_interface=keystone_interface) + if ssl_objects: + for ssl in ssl_objects: + self.configure_cert(ssl['cert'], ssl['key'], cn=ssl['cn']) + self.configure_ca(ssl['ca']) + self.set_state('ssl.enabled', True) + self.configure_apache() + else: + self.set_state('ssl.enabled', False) + amqp_ssl = reactive.RelationBase.from_state('amqp.available.ssl') + if amqp_ssl: + self.configure_rabbit_cert(amqp_ssl) + + def configure_rabbit_cert(self, rabbit_interface): + if not os.path.exists(self.rabbit_client_cert_dir): + os.makedirs(self.rabbit_client_cert_dir) + with open(self.rabbit_cert_file, 'w') as crt: + crt.write(rabbit_interface.get_ssl_cert()) + + @contextlib.contextmanager + def update_central_cacerts(self, cert_files, update_certs=True): + """Update Central certs info if once of cert_files changes""" + checksums = {path: ch_host.path_hash(path) + for path in cert_files} + yield + new_checksums = {path: ch_host.path_hash(path) + for path in cert_files} + if checksums != new_checksums and update_certs: + self.run_update_certs() + + def configure_ca(self, ca_cert, update_certs=True): + """Write Certificate Authority certificate""" + cert_file = ( + '/usr/local/share/ca-certificates/keystone_juju_ca_cert.crt') + if ca_cert: + with self.update_central_cacerts([cert_file], update_certs): + with open(cert_file, 'w') as crt: + crt.write(ca_cert) + + def run_update_certs(self): + """Update certifiacte + + Run update-ca-certificates to update the directory /etc/ssl/certs to + hold SSL certificates and generates ca-certificates.crt, a concatenated + single-file list of certificates + """ + subprocess.check_call(['update-ca-certificates', '--fresh']) + + def update_peers(self, cluster): + for addr_type in os_ip.ADDRESS_MAP.keys(): + cidr = self.config.get(os_ip.ADDRESS_MAP[addr_type]['config']) + laddr = ch_ip.get_address_in_network(cidr) + if laddr: + cluster.set_address( + os_ip.ADDRESS_MAP[addr_type]['binding'], + laddr) diff --git a/charms_openstack/charm.py b/charms_openstack/charm/core.py similarity index 53% rename from charms_openstack/charm.py rename to charms_openstack/charm/core.py index 82a3861..5de9dcc 100644 --- a/charms_openstack/charm.py +++ b/charms_openstack/charm/core.py @@ -1,50 +1,27 @@ -# Copyright 2016 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. - -# OpenStackCharm() - base class for build OpenStack charms from for the -# reactive framework. - -import base64 import collections import contextlib import functools import itertools import os -import random import re -import string import subprocess import apt_pkg as apt -import charmhelpers.contrib.network.ip as ch_ip import charmhelpers.contrib.openstack.templating as os_templating import charmhelpers.contrib.openstack.utils as os_utils import charmhelpers.core.hookenv as hookenv import charmhelpers.core.host as ch_host import charmhelpers.core.templating -import charmhelpers.core.unitdata as unitdata import charmhelpers.fetch as fetch import charms.reactive as reactive import charms_openstack.adapters as os_adapters import charms_openstack.ip as os_ip - # _releases{} is a dictionary of release -> class that is instantiated # according to the the release that is being requested. i.e. a charm can -# handle more than one release. The OpenStackCharm() derived class sets the +# handle more than one release. The BaseOpenStackCharm() derived class sets the # `release` variable to indicate which release that the charm supports. # Any subsequent releases that need a different/specialised charm uses the # `release` class property to indicate that it handles that release onwards. @@ -59,230 +36,6 @@ _singleton = None # This is to enable the defining code to define which release is used. _release_selector_function = None -VIP_KEY = "vip" -CIDR_KEY = "vip_cidr" -IFACE_KEY = "vip_iface" -APACHE_SSL_VHOST = '/etc/apache2/sites-available/openstack_https_frontend.conf' - -OPENSTACK_RELEASE_KEY = 'charmers.openstack-release-version' - -# handler support for default handlers - -# The default handlers that charms.openstack provides. -ALLOWED_DEFAULT_HANDLERS = [ - 'charm.installed', - 'amqp.connected', - 'shared-db.connected', - 'identity-service.connected', - 'identity-service.available', - 'config.changed', - 'charm.default-select-release', - 'update-status', - 'upgrade-charm', -] - -# Where to store the default handler functions for each default state -_default_handler_map = {} - - -def use_defaults(*defaults): - """Activate the default functionality for various handlers - - This is to provide default functionality for common operations for - openstack charms. - """ - for state in defaults: - if state in ALLOWED_DEFAULT_HANDLERS: - if state in _default_handler_map: - # Initialise the default handler for this state - _default_handler_map[state]() - else: - raise RuntimeError( - "State '{}' is allowed, but has no handler???" - .format(state)) - else: - raise RuntimeError("Default handler for '{}' doesn't exist" - .format(state)) - - -def _map_default_handler(state): - """Decorator to map a default handler to a state -- just makes adding - handlers a bit easier. - - :param state: the state that the handler is for. - :raises RuntimeError: if the state doesn't exist in - ALLOWED_DEFAULT_HANDLERS - """ - def wrapper(f): - if state in _default_handler_map: - raise RuntimeError( - "State '{}' can't have more than one default handler" - .format(state)) - if state not in ALLOWED_DEFAULT_HANDLERS: - raise RuntimeError( - "State '{} doesn't have a default handler????".format(state)) - _default_handler_map[state] = f - return f - return wrapper - - -@_map_default_handler('charm.installed') -def make_default_install_handler(): - - @reactive.when_not('charm.installed') - def default_install(): - """Provide a default install handler - - The instance automagically becomes the derived OpenStackCharm instance. - The kv() key charmers.openstack-release-version' is used to cache the - release being used for this charm. It is determined by the - default_select_release() function below, unless this is overriden by - the charm author - """ - unitdata.kv().unset(OPENSTACK_RELEASE_KEY) - OpenStackCharm.singleton.install() - reactive.set_state('charm.installed') - - -@_map_default_handler('charm.default-select-release') -def make_default_select_release_handler(): - """This handler is a bit more unusual, as it just sets the release selector - using the @register_os_release_selector decorator - """ - - @register_os_release_selector - def default_select_release(): - """Determine the release based on the python-keystonemiddleware that is - installed. - - Note that this function caches the release after the first install so - that it doesn't need to keep going and getting it from the package - information. - """ - release_version = unitdata.kv().get(OPENSTACK_RELEASE_KEY, None) - if release_version is None: - release_version = os_utils.os_release('python-keystonemiddleware') - unitdata.kv().set(OPENSTACK_RELEASE_KEY, release_version) - return release_version - - -@_map_default_handler('amqp.connected') -def make_default_amqp_connection_handler(): - - @reactive.when('amqp.connected') - def default_amqp_connection(amqp): - """Handle the default amqp connection. - - This requires that the charm implements get_amqp_credentials() to - provide a tuple of the (user, vhost) for the amqp server - """ - instance = OpenStackCharm.singleton - user, vhost = instance.get_amqp_credentials() - amqp.request_access(username=user, vhost=vhost) - instance.assess_status() - - -@_map_default_handler('shared-db.connected') -def make_default_setup_database_handler(): - - @reactive.when('shared-db.connected') - def default_setup_database(database): - """Handle the default database connection setup - - This requires that the charm implements get_database_setup() to provide - a list of dictionaries; - [{'database': ..., 'username': ..., 'hostname': ..., 'prefix': ...}] - - The prefix can be missing: it defaults to None. - """ - instance = OpenStackCharm.singleton - for db in instance.get_database_setup(): - database.configure(**db) - instance.assess_status() - - -@_map_default_handler('identity-service.connected') -def make_default_setup_endpoint_connection(): - - @reactive.when('identity-service.connected') - def default_setup_endpoint_connection(keystone): - """When the keystone interface connects, register this unit into the - catalog. This is the default handler, and calls on the charm class to - provide the endpoint information. If multiple endpoints are needed, - then a custom endpoint handler will be needed. - """ - instance = OpenStackCharm.singleton - keystone.register_endpoints(instance.service_type, - instance.region, - instance.public_url, - instance.internal_url, - instance.admin_url) - instance.assess_status() - - -@_map_default_handler('identity-service.available') -def make_setup_endpoint_available_handler(): - - @reactive.when('identity-service.available') - def default_setup_endpoint_available(keystone): - """When the identity-service interface is available, this default - handler switches on the SSL support. - """ - instance = OpenStackCharm.singleton - instance.configure_ssl(keystone) - instance.assess_status() - - -@_map_default_handler('config.changed') -def make_default_config_changed_handler(): - - @reactive.when('config.changed') - def default_config_changed(): - """Default handler for config.changed state from reactive. Just see if - our status has changed. This is just to clear any errors that may have - got stuck due to missing async handlers, etc. - """ - instance = OpenStackCharm.singleton - instance.config_changed() - instance.assess_status() - - -@_map_default_handler('upgrade-charm') -def make_default_upgrade_charm_handler(): - - @reactive.hook('update-charm') - def default_upgrade_charm(): - """Default handler for the 'upgrade-charm' hook. - This calls the charm.singleton.upgrade_charm() function as a default. - """ - OpenStackCharm.singleton.upgrade_charm() - - -def default_render_configs(*interfaces): - """Default renderer for configurations. Really just a proxy for - OpenstackCharm.singleton.render_configs(..) with a call to update the - workload status afterwards. - - :params *interfaces: the list of interfaces to provide to the - render_configs() function - """ - instance = OpenStackCharm.singleton - instance.render_configs(interfaces) - instance.assess_status() - - -@_map_default_handler('update-status') -def make_default_update_status_handler(): - - @reactive.hook('update-status') - def default_update_status(): - """Default handler for update-status state. - Just call update status. - """ - OpenStackCharm.singleton.assess_status() - - -# End of default handlers def optional_interfaces(args, *interfaces): """Return a tuple with possible optional interfaces @@ -321,19 +74,17 @@ class provide_charm_instance(object): functools.update_wrapper(self, f) def __call__(self, *args, **kwargs): - return self.f(OpenStackCharm.singleton, *args, **kwargs) + return self.f(BaseOpenStackCharm.singleton, *args, **kwargs) def __enter__(self): """with statement as gets the charm instance""" - return OpenStackCharm.singleton + return BaseOpenStackCharm.singleton def __exit__(self, *_): # Never bother with the exception return False -# Start of charm definitions - def get_charm_instance(release=None, *args, **kwargs): """Get an instance of the charm based on the release (or use the default if release is None). @@ -345,10 +96,11 @@ def get_charm_instance(release=None, *args, **kwargs): Note that it passes args and kwargs to the class __init__() method. :param release: lc string representing release wanted. - :returns: OpenStackCharm() derived class according to cls.releases + :returns: BaseOpenStackCharm() derived class according to cls.releases """ if len(_releases.keys()) == 0: - raise RuntimeError("No derived OpenStackCharm() classes registered") + raise RuntimeError( + "No derived BaseOpenStackCharm() classes registered") # Note that this relies on OS releases being in alphabetica order known_releases = sorted(_releases.keys()) cls = None @@ -402,14 +154,14 @@ def register_os_release_selector(f): return f -class OpenStackCharmMeta(type): +class BaseOpenStackCharmMeta(type): """Metaclass to provide a classproperty of 'singleton' so that class - methods in the derived OpenStackCharm() class can simply use cls.singleton - to get the instance of the charm. + methods in the derived BaseOpenStackCharm() class can simply use + cls.singleton to get the instance of the charm. Thus cls.singleton is a singleton for accessing and creating the default - OpenStackCharm() derived class. This is to avoid a lot of boilerplate in - the classmethods for the charm code. This is because, usually, a + BaseOpenStackCharm() derived class. This is to avoid a lot of boilerplate + in the classmethods for the charm code. This is because, usually, a classmethod is only called once per invocation of the script. Thus in the derived charm code we can do this: @@ -423,12 +175,11 @@ class OpenStackCharmMeta(type): """ def __init__(cls, name, mro, members): - """Receive the OpenStackCharm() (derived) class and store the release - that it works against. Each class defines a 'release' that it handles - and the order of releases (as given in charmhelpers) determines (for - any release) which OpenStackCharm() derived class is the handler for - that class. Note, that if the `name` is 'OpenStackCharm' then the - function ignores the release, etc. + """Receive the BaseOpenStackCharm() (derived) class and store the + release that it works against. Each class defines a 'release' that it + handles and the order of releases (as given in charmhelpers) determines + (for any release) which BaseOpenStackCharm() derived class is the + handler for that class. :param name: string for class name. :param mro: tuple of base classes. @@ -474,61 +225,16 @@ class OpenStackCharmMeta(type): return _singleton -class OpenStackCharm(object, metaclass=OpenStackCharmMeta): +class BaseOpenStackCharm(object, metaclass=BaseOpenStackCharmMeta): """ Base class for all OpenStack Charm classes; - encapulates general OpenStack charm payload operations - Theory: - Derive form this class, set the name, first_release and releases class - variables so that get_charm_instance() will create an instance of this - charm. - - See the other class variables for details on what they are for and do. + It implements the basic plumbing to support a singleton object representing + the current series of OpenStack in use. """ abstract_class = True - # first_release = this is the first release in which this charm works - release = 'icehouse' - - # The name of the charm (for printing, etc.) - name = 'charmname' - - # List of packages to install - packages = [] - - # Package to determine application version from - # defaults to first in packages if not provided - version_package = None - - # Dictionary mapping services to ports for public, admin and - # internal endpoints - api_ports = {} - - # Keystone endpoint type - service_type = None - - # Default service for the charm - default_service = None - - # A dictionary of: - # { - # 'config.file': ['list', 'of', 'services', 'to', 'restart'], - # 'config2.file': ['more', 'services'], - # } - restart_map = {} - - # The list of required services that are checked for assess_status - # e.g. required_relations = ['identity-service', 'shared-db'] - required_relations = [] - - # The command used to sync the database - sync_cmd = [] - - # The list of services that this charm manages - services = [] - # The adapters class that this charm uses to adapt interfaces. # If None, then it defaults to OpenstackRelationsAdapter adapters_class = os_adapters.OpenStackRelationAdapters @@ -537,9 +243,10 @@ class OpenStackCharm(object, metaclass=OpenStackCharmMeta): # If None, then the default ConfigurationAdapter is used. configuration_class = os_adapters.ConfigurationAdapter - ha_resources = [] - HAPROXY_CONF = '/etc/haproxy/haproxy.cfg' - MEMCACHE_CONF = '/etc/memcached.conf' + # Dictionary mapping services to ports for public, admin and + # internal endpoints + api_ports = {} + package_codenames = {} @property @@ -557,13 +264,14 @@ class OpenStackCharm(object, metaclass=OpenStackCharmMeta): :param interfaces: list of interface instances for the charm. :param config: the config for the charm (optionally None for automatically using config()) + :param release: the release for this instance or None """ self.config = config or hookenv.config() self.release = release self.__adapters_instance = None self.__interfaces = interfaces or [] self.__options = None - self.__run_assess_status = False + super().__init__() @property def adapters_instance(self): @@ -581,68 +289,6 @@ class OpenStackCharm(object, metaclass=OpenStackCharmMeta): self.__interfaces, charm_instance=self) return self.__adapters_instance - @property - def options(self): - """Lazily return the options for the charm when this is first called - - We want the fancy options here too that's normally on the adapters - class as it means the charm get access to computed options as well. - - :returns: an options instance based on the configuration_class - """ - if self.__options is None: - self.__options = os_adapters.make_default_options( - base_cls=getattr(self, 'configuration_class', None), - charm_instance=self) - return self.__options - - @property - def all_packages(self): - """List of packages to be installed - - @return ['pkg1', 'pkg2', ...] - """ - return self.packages - - @property - def full_restart_map(self): - """Map of services to be restarted if a file changes - - @return { - 'file1': ['svc1', 'svc3'], - 'file2': ['svc2', 'svc3'], - ... - } - """ - return self.restart_map - - def install(self): - """Install packages related to this charm based on - contents of self.packages attribute. - """ - packages = fetch.filter_installed_packages( - self.all_packages) - if packages: - hookenv.status_set('maintenance', 'Installing packages') - fetch.apt_install(packages, fatal=True) - # AJK: we set this as charms can use it to detect installed state - self.set_state('{}-installed'.format(self.name)) - self.update_api_ports() - hookenv.status_set('maintenance', - 'Installation complete - awaiting next status') - - def set_state(self, state, value=None): - """proxy for charms.reactive.bus.set_state()""" - reactive.bus.set_state(state, value) - - def remove_state(self, state): - """proxy for charms.reactive.bus.remove_state()""" - reactive.bus.remove_state(state) - - def get_state(self, state): - """proxy for charms.reactive.bus.get_state()""" - return reactive.bus.get_state(state) - def get_adapter(self, state, adapters_instance=None): """Get the adapted interface for a state or None if the state doesn't yet exist. @@ -664,6 +310,21 @@ class OpenStackCharm(object, metaclass=OpenStackCharmMeta): _, adapter = adapters_instance.make_adapter(interface) return adapter + @property + def options(self): + """Lazily return the options for the charm when this is first called + + We want the fancy options here too that's normally on the adapters + class as it means the charm get access to computed options as well. + + :returns: an options instance based on the configuration_class + """ + if self.__options is None: + self.__options = os_adapters.make_default_options( + base_cls=getattr(self, 'configuration_class', None), + charm_instance=self) + return self.__options + def api_port(self, service, endpoint_type=os_ip.PUBLIC): """Return the API port for a particular endpoint type from the self.api_ports{}. @@ -674,54 +335,125 @@ class OpenStackCharm(object, metaclass=OpenStackCharmMeta): """ return self.api_ports[service][endpoint_type] - def update_api_ports(self, ports=None): - """Update the ports list supplied (or the default ports defined in the - classes' api_ports member) using the juju helper. + def set_state(self, state, value=None): + """proxy for charms.reactive.bus.set_state()""" + reactive.bus.set_state(state, value) - It takes the opened-ports from Juju, checks them against the ports - provided. If a port is already open, then it doesn't try to open it, - if it is closed, but should be open, then it opens it, and vice-versa. + def remove_state(self, state): + """proxy for charms.reactive.bus.remove_state()""" + reactive.bus.remove_state(state) - :param ports: List of api port numbers or None. - """ - ports = list(map(int, ( - ports or self._default_port_list(self.api_ports or {})))) - current_ports = list(map(int, self.opened_ports())) - ports_to_open = set(ports).difference(current_ports) - ports_to_close = set(current_ports).difference(ports) - for p in ports_to_open: - hookenv.open_port(p) - for p in ports_to_close: - hookenv.close_port(p) + def get_state(self, state): + """proxy for charms.reactive.bus.get_state()""" + return reactive.bus.get_state(state) @staticmethod - def opened_ports(protocol="tcp"): - """Return a list of ports according to the protocol provided - Open a service network port + def get_os_codename_package(package, codenames, fatal=True): + """Derive OpenStack release codename from an installed package. - If protocol is intentionally set to None, then the list will be the - list returnted by the Juju opened-ports command. - - :param (OPTIONAL) protocol: the protocol to check, TCP/UDP or None - :returns: List of ports open, according to the protocol + :param package: str Package name to lookup in apt cache + :param codenames: dict of OrderedDict eg + { + 'pkg1': collections.OrderedDict([ + ('2', 'mitaka'), + ('3', 'newton'), + ('4', 'ocata'), ]), + 'pkg2': collections.OrderedDict([ + ('12.6', 'mitaka'), + ('13.2', 'newton'), + ('14.7', 'ocata'), ]), + } + :param fatal: bool Raise exception if pkg not installed + :returns: str OpenStack version name corresponding to package """ - _args = ['opened-ports'] - if protocol: - protocol = protocol.lower() - else: - protocol = '' - lines = [l for l in - subprocess.check_output(_args).decode('UTF-8').split() - if l] - ports = [] - for line in lines: - p, p_type = line.split('/') - if protocol: - if protocol == p_type.lower(): - ports.append(p) - else: - ports.append(line) - return ports + cache = fetch.apt_cache() + + try: + pkg = cache[package] + except KeyError: + if not fatal: + return None + # the package is unknown to the current apt cache. + e = ('Could not determine version of package with no installation ' + 'candidate: {}'.format(package)) + raise Exception(e) + if not pkg.current_ver: + if not fatal: + return None + + vers = apt.upstream_version(pkg.current_ver.ver_str) + # x.y match only for 20XX.X + # and ignore patch level for other packages + match = re.match('^(\d+)\.(\d+)', vers) + + if match: + vers = match.group(0) + + # Generate a major version number for newer semantic + # versions of openstack projects + major_vers = vers.split('.')[0] + if (package in codenames and + major_vers in codenames[package]): + return codenames[package][major_vers] + + def get_os_version_package(self, package, fatal=True): + """Derive OpenStack version number from an installed package. + + :param package: str Package name to lookup in apt cache + :param fatal: bool Raise exception if pkg not installed + :returns: str OpenStack version number corresponding to package + """ + codenames = self.package_codenames or os_utils.PACKAGE_CODENAMES + codename = self.get_os_codename_package( + package, codenames, fatal=fatal) + if not codename: + return None + + vers_map = os_utils.OPENSTACK_CODENAMES + for version, cname in vers_map.items(): + if cname == codename: + return version + + +class BaseOpenStackCharmActions(object): + """Default actions that an OpenStack charm can expect to have to do. + + This includes things like 'installation', 'rendering configurations', etc. + + It is designed as a mixin, and is separated out so that it is easier to + maintain. + + i.e. + + class OpenStackCharm(BaseOpenStackCharm, + BaseOpenStackCharmActions): + ... stuff ... + """ + + @property + def all_packages(self): + """List of packages to be installed + + Relies on the class variable 'packages' + + @return ['pkg1', 'pkg2', ...] + """ + return self.packages + + def install(self): + """Install packages related to this charm based on + contents of self.packages attribute. + """ + packages = fetch.filter_installed_packages( + self.all_packages) + if packages: + hookenv.status_set('maintenance', 'Installing packages') + fetch.apt_install(packages, fatal=True) + # AJK: we set this as charms can use it to detect installed state + self.set_state('{}-installed'.format(self.name)) + self.update_api_ports() + hookenv.status_set('maintenance', + 'Installation complete - awaiting next status') def configure_source(self): """Configure installation source using the config item @@ -734,51 +466,16 @@ class OpenStackCharm(object, metaclass=OpenStackCharmMeta): fetch.apt_update(fatal=True) @property - def region(self): - """Return the OpenStack Region as contained in the config item 'region' - """ - return self.config['region'] + def full_restart_map(self): + """Map of services to be restarted if a file changes - @property - def public_url(self): - """Return the public endpoint URL for the default service as specified - in the self.default_service attribute + @return { + 'file1': ['svc1', 'svc3'], + 'file2': ['svc2', 'svc3'], + ... + } """ - return "{}:{}".format(os_ip.canonical_url(os_ip.PUBLIC), - self.api_port(self.default_service, - os_ip.PUBLIC)) - - @property - def admin_url(self): - """Return the admin endpoint URL for the default service as specificed - in the self.default_service attribute - """ - return "{}:{}".format(os_ip.canonical_url(os_ip.ADMIN), - self.api_port(self.default_service, - os_ip.ADMIN)) - - @property - def internal_url(self): - """Return the internal internal endpoint URL for the default service as - specificated in the self.default_service attribtue - """ - return "{}:{}".format(os_ip.canonical_url(os_ip.INTERNAL), - self.api_port(self.default_service, - os_ip.INTERNAL)) - - @property - def application_version(self): - """Return the current version of the application being deployed by - the charm, as indicated by the version_package attribute - """ - if not self.version_package: - self.version_package = self.packages[0] - version = get_upstream_version( - self.version_package - ) - if not version: - version = os_utils.os_release(self.version_package) - return version + return self.restart_map @contextlib.contextmanager def restart_on_change(self): @@ -803,6 +500,13 @@ class OpenStackCharm(object, metaclass=OpenStackCharmMeta): for service_name in services_list: ch_host.service_start(service_name) + def restart_all(self): + """Restart all the services configured in the self.services[] + attribute. + """ + for svc in self.services: + ch_host.service_restart(svc) + def render_all_configs(self, adapters_instance=None): """Render (write) all of the config files identified as the keys in the self.restart_map{} @@ -868,13 +572,6 @@ class OpenStackCharm(object, metaclass=OpenStackCharmMeta): configs, adapters_instance=self.adapters_class(interfaces)) - def restart_all(self): - """Restart all the services configured in the self.services[] - attribute. - """ - for svc in self.services: - ch_host.service_restart(svc) - def db_sync_done(self): return hookenv.leader_get(attribute='db-sync-done') @@ -897,6 +594,159 @@ class OpenStackCharm(object, metaclass=OpenStackCharmMeta): """ pass + def upgrade_charm(self): + """Called (at least) by the default handler (if that is used). This + version just checks that the ports that are open should be open and + that the ports that are closed should be closed. If the charm upgrade + alters the ports then update_api_ports() function will adjust the ports + as needed. + """ + self.update_api_ports() + + def update_api_ports(self, ports=None): + """Update the ports list supplied (or the default ports defined in the + classes' api_ports member) using the juju helper. + + It takes the opened-ports from Juju, checks them against the ports + provided. If a port is already open, then it doesn't try to open it, + if it is closed, but should be open, then it opens it, and vice-versa. + + :param ports: List of api port numbers or None. + """ + ports = list(map(int, ( + ports or self._default_port_list(self.api_ports or {})))) + current_ports = list(map(int, self.opened_ports())) + ports_to_open = set(ports).difference(current_ports) + ports_to_close = set(current_ports).difference(ports) + for p in ports_to_open: + hookenv.open_port(p) + for p in ports_to_close: + hookenv.close_port(p) + + @staticmethod + def opened_ports(protocol="tcp"): + """Return a list of ports according to the protocol provided + Open a service network port + + If protocol is intentionally set to None, then the list will be the + list returnted by the Juju opened-ports command. + + :param (OPTIONAL) protocol: the protocol to check, TCP/UDP or None + :returns: List of ports open, according to the protocol + """ + _args = ['opened-ports'] + if protocol: + protocol = protocol.lower() + else: + protocol = '' + lines = [l for l in + subprocess.check_output(_args).decode('UTF-8').split() + if l] + ports = [] + for line in lines: + p, p_type = line.split('/') + if protocol: + if protocol == p_type.lower(): + ports.append(p) + else: + ports.append(line) + return ports + + def openstack_upgrade_available(self, package=None): + """Check if an OpenStack upgrade is available + + :param package: str Package name to use to check upgrade availability + :returns: bool + """ + if not package: + package = self.release_pkg + + src = self.config['openstack-origin'] + cur_vers = self.get_os_version_package(package) + avail_vers = os_utils.get_os_version_install_source(src) + apt.init() + return apt.version_compare(avail_vers, cur_vers) == 1 + + def upgrade_if_available(self, interfaces_list): + """Upgrade OpenStack if an upgrade is available + + :param interfaces_list: List of instances of interface classes + :returns: None + """ + if self.openstack_upgrade_available(self.release_pkg): + hookenv.status_set('maintenance', 'Running openstack upgrade') + self.do_openstack_pkg_upgrade() + self.do_openstack_upgrade_config_render(interfaces_list) + self.do_openstack_upgrade_db_migration() + + def do_openstack_pkg_upgrade(self): + """Upgrade OpenStack packages + + :returns: None + """ + new_src = self.config['openstack-origin'] + new_os_rel = os_utils.get_os_codename_install_source(new_src) + hookenv.log('Performing OpenStack upgrade to %s.' % (new_os_rel)) + + os_utils.configure_installation_source(new_src) + fetch.apt_update() + + dpkg_opts = [ + '--option', 'Dpkg::Options::=--force-confnew', + '--option', 'Dpkg::Options::=--force-confdef', + ] + fetch.apt_upgrade( + options=dpkg_opts, + fatal=True, + dist=True) + fetch.apt_install( + packages=self.all_packages, + options=dpkg_opts, + fatal=True) + self.release = new_os_rel + + def do_openstack_upgrade_config_render(self, interfaces_list): + """Render configs after upgrade + + :returns: None + """ + self.render_with_interfaces(interfaces_list) + + def do_openstack_upgrade_db_migration(self): + """Run database migration after upgrade + + :returns: None + """ + if hookenv.is_leader(): + subprocess.check_call(self.sync_cmd) + else: + hookenv.log("Deferring DB sync to leader", level=hookenv.INFO) + + +class BaseOpenStackCharmAssessStatus(object): + """Provides the 'Assess Status' functionality to the OpenStack charm class. + + It is designed as a mixin, and is separated out so that it is easier to + maintain. + + i.e. + + class OpenStackCharm(BaseOpenStackCharm, + BaseOpenStackCharmAssessStatus): + ... stuff ... + + + Relies on the following class or object variables: + + # The list of services that this charm manages + services = [] + """ + + def __init__(self, *args, **kwargs): + """Set up specific mixin requirements""" + self.__run_assess_status = False + super().__init__(*args, **kwargs) + def _assess_status(self): """Assess the status of the unit and set the status and a useful message as appropriate. @@ -920,10 +770,8 @@ class OpenStackCharm(object, metaclass=OpenStackCharmMeta): override :meth:`ports_to_check()` and return an empty list. SIDE EFFECT: this function calls status_set(state, message) to set the - workload status in juju and calls application_version_set(vers) to set - the application version in juju. + workload status in juju. """ - hookenv.application_version_set(self.application_version) for f in [self.check_if_paused, self.custom_assess_status_check, self.check_interfaces, @@ -974,6 +822,28 @@ class OpenStackCharm(object, metaclass=OpenStackCharmMeta): services=self.services, ports=self.ports_to_check(self.api_ports)) + def ports_to_check(self, ports): + """Return a flattened, sorted, unique list of ports from self.api_ports + + NOTE. To disable port checking, simply override this method in the + derived class and return an empty []. + + :param ports: {key: {subkey: value}} + :returns: [value1, value2, ...] + """ + return self._default_port_list(ports) + + def _default_port_list(self, ports): + """Return a flattened, sorted, unique list of ports from self.api_ports + + :param ports: {key: {subkey: value}} + :return: [value1, value2, ...] + """ + # NB api_ports = {key: {space: value}} + # The chain .. map flattens all the values into a single list + return sorted(set(itertools.chain(*map(lambda x: x.values(), + ports.values())))) + def check_interfaces(self): """Check that the required interfaces have both connected and availble states set. @@ -1059,618 +929,3 @@ class OpenStackCharm(object, metaclass=OpenStackCharmMeta): return os_utils._ows_check_services_running( services=self.services, ports=self.ports_to_check(self.api_ports)) - - def upgrade_charm(self): - """Called (at least) by the default handler (if that is used). This - version just checks that the ports that are open should be open and - that the ports that are closed should be closed. If the charm upgrade - alters the ports then update_api_ports() function will adjust the ports - as needed. - """ - self.update_api_ports() - - def ports_to_check(self, ports): - """Return a flattened, sorted, unique list of ports from self.api_ports - - NOTE. To disable port checking, simply override this method in the - derived class and return an empty []. - - :param ports: {key: {subkey: value}} - :returns: [value1, value2, ...] - """ - return self._default_port_list(ports) - - def _default_port_list(self, ports): - """Return a flattened, sorted, unique list of ports from self.api_ports - - :param ports: {key: {subkey: value}} - :return: [value1, value2, ...] - """ - # NB api_ports = {key: {space: value}} - # The chain .. map flattens all the values into a single list - return sorted(set(itertools.chain(*map(lambda x: x.values(), - ports.values())))) - - @staticmethod - def get_os_codename_package(package, codenames, fatal=True): - """Derive OpenStack release codename from an installed package. - - :param package: str Package name to lookup in apt cache - :param codenames: dict of OrderedDict eg - { - 'pkg1': collections.OrderedDict([ - ('2', 'mitaka'), - ('3', 'newton'), - ('4', 'ocata'), ]), - 'pkg2': collections.OrderedDict([ - ('12.6', 'mitaka'), - ('13.2', 'newton'), - ('14.7', 'ocata'), ]), - } - :param fatal: bool Raise exception if pkg not installed - :returns: str OpenStack version name corresponding to package - """ - cache = fetch.apt_cache() - - try: - pkg = cache[package] - except KeyError: - if not fatal: - return None - # the package is unknown to the current apt cache. - e = ('Could not determine version of package with no installation ' - 'candidate: {}'.format(package)) - raise Exception(e) - if not pkg.current_ver: - if not fatal: - return None - - vers = apt.upstream_version(pkg.current_ver.ver_str) - # x.y match only for 20XX.X - # and ignore patch level for other packages - match = re.match('^(\d+)\.(\d+)', vers) - - if match: - vers = match.group(0) - - # Generate a major version number for newer semantic - # versions of openstack projects - major_vers = vers.split('.')[0] - if (package in codenames and - major_vers in codenames[package]): - return codenames[package][major_vers] - - def get_os_version_package(self, package, fatal=True): - """Derive OpenStack version number from an installed package. - - :param package: str Package name to lookup in apt cache - :param fatal: bool Raise exception if pkg not installed - :returns: str OpenStack version number corresponding to package - """ - codenames = self.package_codenames or os_utils.PACKAGE_CODENAMES - codename = self.get_os_codename_package( - package, codenames, fatal=fatal) - if not codename: - return None - - vers_map = os_utils.OPENSTACK_CODENAMES - for version, cname in vers_map.items(): - if cname == codename: - return version - - def openstack_upgrade_available(self, package=None): - """Check if an OpenStack upgrade is available - - :param package: str Package name to use to check upgrade availability - :returns: bool - """ - if not package: - package = self.release_pkg - - src = self.config['openstack-origin'] - cur_vers = self.get_os_version_package(package) - avail_vers = os_utils.get_os_version_install_source(src) - apt.init() - return apt.version_compare(avail_vers, cur_vers) == 1 - - def upgrade_if_available(self, interfaces_list): - """Upgrade OpenStack if an upgrade is available - - :param interfaces_list: List of instances of interface classes - :returns: None - """ - if self.openstack_upgrade_available(self.release_pkg): - hookenv.status_set('maintenance', 'Running openstack upgrade') - self.do_openstack_pkg_upgrade() - self.do_openstack_upgrade_config_render(interfaces_list) - self.do_openstack_upgrade_db_migration() - - def do_openstack_pkg_upgrade(self): - """Upgrade OpenStack packages - - :returns: None - """ - new_src = self.config['openstack-origin'] - new_os_rel = os_utils.get_os_codename_install_source(new_src) - hookenv.log('Performing OpenStack upgrade to %s.' % (new_os_rel)) - - os_utils.configure_installation_source(new_src) - fetch.apt_update() - - dpkg_opts = [ - '--option', 'Dpkg::Options::=--force-confnew', - '--option', 'Dpkg::Options::=--force-confdef', - ] - fetch.apt_upgrade( - options=dpkg_opts, - fatal=True, - dist=True) - fetch.apt_install( - packages=self.all_packages, - options=dpkg_opts, - fatal=True) - self.release = new_os_rel - - def do_openstack_upgrade_config_render(self, interfaces_list): - """Render configs after upgrade - - :returns: None - """ - self.render_with_interfaces(interfaces_list) - - def do_openstack_upgrade_db_migration(self): - """Run database migration after upgrade - - :returns: None - """ - if hookenv.is_leader(): - subprocess.check_call(self.sync_cmd) - else: - hookenv.log("Deferring DB sync to leader", level=hookenv.INFO) - - -class OpenStackAPICharm(OpenStackCharm): - """The base class for API OS charms -- this just bakes in the default - configuration and adapter classes. - """ - abstract_class = True - - # The adapters class that this charm uses to adapt interfaces. - # If None, then it defaults to OpenstackRelationAdapters - adapters_class = os_adapters.OpenStackAPIRelationAdapters - - # The configuration base class to use for the charm - # If None, then the default ConfigurationAdapter is used. - configuration_class = os_adapters.APIConfigurationAdapter - - def upgrade_charm(self): - """Setup token cache in case previous charm version did not.""" - self.setup_token_cache() - super(OpenStackAPICharm, self).upgrade_charm() - - def install(self): - """Install packages related to this charm based on - contents of self.packages attribute. - """ - self.configure_source() - super(OpenStackAPICharm, self).install() - - def setup_token_cache(self): - """Check if a token cache package is needed and install it if it is""" - if fetch.filter_installed_packages(self.token_cache_pkgs()): - self.install() - - def enable_memcache(self, release=None): - """Determine if memcache should be enabled on the local unit - - @param release: release of OpenStack currently deployed - @returns boolean Whether memcache should be enabled - """ - if not release: - release = os_utils.get_os_codename_install_source( - self.config['openstack-origin']) - if release not in os_utils.OPENSTACK_RELEASES: - return ValueError("Unkown release {}".format(release)) - return (os_utils.OPENSTACK_RELEASES.index(release) >= - os_utils.OPENSTACK_RELEASES.index('mitaka')) - - def token_cache_pkgs(self, release=None): - """Determine additional packages needed for token caching - - @param release: release of OpenStack currently deployed - @returns List of package to enable token caching - """ - packages = [] - if self.enable_memcache(release=release): - packages.extend(['memcached', 'python-memcache']) - return packages - - def get_amqp_credentials(self): - """Provide the default amqp username and vhost as a tuple. - - This needs to be overriden in a derived class to provide the username - and vhost to the amqp interface IF the default amqp handlers are being - used. - :returns (username, host): two strings to send to the amqp provider. - """ - raise RuntimeError( - "get_amqp_credentials() needs to be overriden in the derived " - "class") - - def get_database_setup(self): - """Provide the default database credentials as a list of 3-tuples - - This is used when using the default handlers for the shared-db service - and provides the (db, db_user, ip) for each database as a list. - - returns a structure of: - [ - {'database': , - 'username': , - 'hostname': - 'prefix': , }, - ] - - This allows multiple databases to be setup. - - If more complex database setup is required, then the default - setup_database() will need to be ignored, and a custom function - written. - - :returns [{'database': ...}, ...]: credentials for multiple databases - """ - raise RuntimeError( - "get_database_setup() needs to be overriden in the derived " - "class") - - @property - def all_packages(self): - """List of packages to be installed - - @return ['pkg1', 'pkg2', ...] - """ - return (super(OpenStackAPICharm, self).all_packages + - self.token_cache_pkgs()) - - @property - def full_restart_map(self): - """Map of services to be restarted if a file changes - - @return { - 'file1': ['svc1', 'svc3'], - 'file2': ['svc2', 'svc3'], - ... - } - """ - _restart_map = super(OpenStackAPICharm, self).full_restart_map.copy() - if self.enable_memcache(): - _restart_map[self.MEMCACHE_CONF] = ['memcached'] - return _restart_map - - -class HAOpenStackCharm(OpenStackAPICharm): - - abstract_class = True - - def __init__(self, **kwargs): - super(HAOpenStackCharm, self).__init__(**kwargs) - self.set_haproxy_stat_password() - - @property - def apache_vhost_file(self): - """Apache vhost for SSL termination - - :returns: string - """ - return APACHE_SSL_VHOST - - def enable_apache_ssl_vhost(self): - """Enable Apache vhost for SSL termination - - Enable Apache vhost for SSL termination if vhost exists and it is not - curently enabled - """ - if os.path.exists(self.apache_vhost_file): - check_enabled = subprocess.call( - ['a2query', '-s', 'openstack_https_frontend']) - if check_enabled != 0: - subprocess.check_call(['a2ensite', 'openstack_https_frontend']) - ch_host.service_reload('apache2', restart_on_failure=True) - - def configure_apache(self): - if self.apache_enabled(): - self.install() - self.enable_apache_modules() - self.enable_apache_ssl_vhost() - - @property - def all_packages(self): - """List of packages to be installed - - @return ['pkg1', 'pkg2', ...] - """ - _packages = super(HAOpenStackCharm, self).all_packages - if self.haproxy_enabled(): - _packages.append('haproxy') - if self.apache_enabled(): - _packages.append('apache2') - return _packages - - @property - def full_restart_map(self): - """Map of services to be restarted if a file changes - - @return { - 'file1': ['svc1', 'svc3'], - 'file2': ['svc2', 'svc3'], - ... - } - """ - _restart_map = super(HAOpenStackCharm, self).full_restart_map - if self.haproxy_enabled(): - _restart_map[self.HAPROXY_CONF] = ['haproxy'] - if self.apache_enabled(): - _restart_map[self.apache_vhost_file] = ['apache2'] - return _restart_map - - def apache_enabled(self): - """Determine if apache is being used - - @return True if apache is being used""" - return self.get_state('ssl.enabled') - - def haproxy_enabled(self): - """Determine if haproxy is fronting the services - - @return True if haproxy is fronting the service""" - return 'haproxy' in self.ha_resources - - def configure_ha_resources(self, hacluster): - """Inform the ha subordinate about each service it should manage. The - child class specifies the services via self.ha_resources - - @param hacluster instance of interface class HAClusterRequires - """ - RESOURCE_TYPES = { - 'vips': self._add_ha_vips_config, - 'haproxy': self._add_ha_haproxy_config, - } - if self.ha_resources: - for res_type in self.ha_resources: - RESOURCE_TYPES[res_type](hacluster) - hacluster.bind_resources(iface=self.config[IFACE_KEY]) - - def _add_ha_vips_config(self, hacluster): - """Add a VirtualIP object for each user specified vip to self.resources - - @param hacluster instance of interface class HAClusterRequires - """ - for vip in self.config.get(VIP_KEY, '').split(): - iface = (ch_ip.get_iface_for_address(vip) or - self.config.get(IFACE_KEY)) - netmask = (ch_ip.get_netmask_for_address(vip) or - self.config.get(CIDR_KEY)) - if iface is not None: - hacluster.add_vip(self.name, vip, iface, netmask) - - def _add_ha_haproxy_config(self, hacluster): - """Add a InitService object for haproxy to self.resources - - @param hacluster instance of interface class HAClusterRequires - """ - hacluster.add_init_service(self.name, 'haproxy') - - def set_haproxy_stat_password(self): - """Set a stats password for accessing haproxy statistics""" - if not self.get_state('haproxy.stat.password'): - password = ''.join([ - random.choice(string.ascii_letters + string.digits) - for n in range(32)]) - self.set_state('haproxy.stat.password', password) - - def enable_apache_modules(self): - """Enable Apache modules needed for SSL termination""" - restart = False - for module in ['ssl', 'proxy', 'proxy_http']: - check_enabled = subprocess.call(['a2query', '-m', module]) - if check_enabled != 0: - subprocess.check_call(['a2enmod', module]) - restart = True - if restart: - ch_host.service_restart('apache2') - - def configure_cert(self, cert, key, cn=None): - """Configure service SSL cert and key - - Write out service SSL certificate and key for Apache. - - @param cert string SSL Certificate - @param key string SSL Key - @param cn string Canonical name for service - """ - if not cn: - cn = os_ip.resolve_address(endpoint_type=os_ip.INTERNAL) - ssl_dir = os.path.join('/etc/apache2/ssl/', self.name) - ch_host.mkdir(path=ssl_dir) - if cn: - cert_filename = 'cert_{}'.format(cn) - key_filename = 'key_{}'.format(cn) - else: - cert_filename = 'cert' - key_filename = 'key' - - ch_host.write_file(path=os.path.join(ssl_dir, cert_filename), - content=cert.encode('utf-8')) - ch_host.write_file(path=os.path.join(ssl_dir, key_filename), - content=key.encode('utf-8')) - - def get_local_addresses(self): - """Return list of local addresses on each configured network - - For each network return an address the local unit has on that network - if one exists. - - @returns [private_addr, admin_addr, public_addr, ...] - """ - addresses = [ - os_utils.get_host_ip(hookenv.unit_get('private-address'))] - for addr_type in os_ip.ADDRESS_MAP.keys(): - laddr = os_ip.resolve_address(endpoint_type=addr_type) - if laddr: - addresses.append(laddr) - return sorted(list(set(addresses))) - - def get_certs_and_keys(self, keystone_interface=None): - """Collect SSL config for local endpoints - - SSL keys and certs may come from user specified configuration for this - charm or they may come directly from Keystone. - - If collecting from keystone there may be a certificate and key per - endpoint (public, admin etc). - - @returns [ - {'key': 'key1', 'cert': 'cert1', 'ca': 'ca1', 'cn': 'cn1'} - {'key': 'key2', 'cert': 'cert2', 'ca': 'ca2', 'cn': 'cn2'} - ... - ] - """ - if self.config_defined_ssl_key and self.config_defined_ssl_cert: - return [{ - 'key': self.config_defined_ssl_key.decode('utf-8'), - 'cert': self.config_defined_ssl_cert.decode('utf-8'), - 'ca': self.config_defined_ssl_ca.decode('utf-8'), - 'cn': None}] - elif keystone_interface: - keys_and_certs = [] - for addr in self.get_local_addresses(): - key = keystone_interface.get_ssl_key(addr) - cert = keystone_interface.get_ssl_cert(addr) - ca = keystone_interface.get_ssl_ca() - if key and cert: - keys_and_certs.append({ - 'key': key, - 'cert': cert, - 'ca': ca, - 'cn': addr}) - return keys_and_certs - else: - return [] - - def _get_b64decode_for(self, param): - config_value = self.config.get(param) - if config_value: - return base64.b64decode(config_value) - return None - - @property - @hookenv.cached - def config_defined_ssl_key(self): - return self._get_b64decode_for('ssl_key') - - @property - @hookenv.cached - def config_defined_ssl_cert(self): - return self._get_b64decode_for('ssl_cert') - - @property - @hookenv.cached - def config_defined_ssl_ca(self): - return self._get_b64decode_for('ssl_ca') - - @property - def rabbit_client_cert_dir(self): - return '/var/lib/charm/{}'.format(hookenv.service_name()) - - @property - def rabbit_cert_file(self): - return '{}/rabbit-client-ca.pem'.format(self.rabbit_client_cert_dir) - - def configure_ssl(self, keystone_interface=None): - """Configure SSL certificates and keys - - @param keystone_interface KeystoneRequires class - """ - keystone_interface = (reactive.RelationBase.from_state( - 'identity-service.available.ssl') or - reactive.RelationBase.from_state( - 'identity-service.available.ssl_legacy')) - ssl_objects = self.get_certs_and_keys( - keystone_interface=keystone_interface) - if ssl_objects: - for ssl in ssl_objects: - self.configure_cert(ssl['cert'], ssl['key'], cn=ssl['cn']) - self.configure_ca(ssl['ca']) - self.set_state('ssl.enabled', True) - self.configure_apache() - else: - self.set_state('ssl.enabled', False) - amqp_ssl = reactive.RelationBase.from_state('amqp.available.ssl') - if amqp_ssl: - self.configure_rabbit_cert(amqp_ssl) - - def configure_rabbit_cert(self, rabbit_interface): - if not os.path.exists(self.rabbit_client_cert_dir): - os.makedirs(self.rabbit_client_cert_dir) - with open(self.rabbit_cert_file, 'w') as crt: - crt.write(rabbit_interface.get_ssl_cert()) - - @contextlib.contextmanager - def update_central_cacerts(self, cert_files, update_certs=True): - """Update Central certs info if once of cert_files changes""" - checksums = {path: ch_host.path_hash(path) - for path in cert_files} - yield - new_checksums = {path: ch_host.path_hash(path) - for path in cert_files} - if checksums != new_checksums and update_certs: - self.run_update_certs() - - def configure_ca(self, ca_cert, update_certs=True): - """Write Certificate Authority certificate""" - cert_file = ( - '/usr/local/share/ca-certificates/keystone_juju_ca_cert.crt') - if ca_cert: - with self.update_central_cacerts([cert_file], update_certs): - with open(cert_file, 'w') as crt: - crt.write(ca_cert) - - def run_update_certs(self): - """Update certifiacte - - Run update-ca-certificates to update the directory /etc/ssl/certs to - hold SSL certificates and generates ca-certificates.crt, a concatenated - single-file list of certificates - """ - subprocess.check_call(['update-ca-certificates', '--fresh']) - - def update_peers(self, cluster): - for addr_type in os_ip.ADDRESS_MAP.keys(): - cidr = self.config.get(os_ip.ADDRESS_MAP[addr_type]['config']) - laddr = ch_ip.get_address_in_network(cidr) - if laddr: - cluster.set_address( - os_ip.ADDRESS_MAP[addr_type]['binding'], - laddr) - - -# TODO: drop once charmhelpers releases a new version -# with this function in the fetch helper (> 0.9.1) -def get_upstream_version(package): - """Determine upstream version based on installed package - - @returns None (if not installed) or the upstream version - """ - import apt_pkg - cache = fetch.apt_cache() - try: - pkg = cache[package] - except: - # the package is unknown to the current apt cache. - return None - - if not pkg.current_ver: - # package is known, but no version is currently installed. - return None - - return apt_pkg.upstream_version(pkg.current_ver.ver_str) diff --git a/charms_openstack/charm/defaults.py b/charms_openstack/charm/defaults.py new file mode 100644 index 0000000..c425cd3 --- /dev/null +++ b/charms_openstack/charm/defaults.py @@ -0,0 +1,225 @@ +import charmhelpers.contrib.openstack.utils as os_utils +import charmhelpers.core.hookenv as hookenv +import charmhelpers.core.unitdata as unitdata +import charms.reactive as reactive + +from charms_openstack.charm.classes import OpenStackCharm +from charms_openstack.charm.core import register_os_release_selector + +# The default handlers that charms.openstack provides. +ALLOWED_DEFAULT_HANDLERS = [ + 'charm.installed', + 'amqp.connected', + 'shared-db.connected', + 'identity-service.connected', + 'identity-service.available', + 'config.changed', + 'charm.default-select-release', + 'update-status', + 'upgrade-charm', +] + +# Where to store the default handler functions for each default state +_default_handler_map = {} + +# Used to store the discovered release version for caching between invocations +OPENSTACK_RELEASE_KEY = 'charmers.openstack-release-version' + + +def use_defaults(*defaults): + """Activate the default functionality for various handlers + + This is to provide default functionality for common operations for + openstack charms. + """ + for state in defaults: + if state in ALLOWED_DEFAULT_HANDLERS: + if state in _default_handler_map: + # Initialise the default handler for this state + _default_handler_map[state]() + else: + raise RuntimeError( + "State '{}' is allowed, but has no handler???" + .format(state)) + else: + raise RuntimeError("Default handler for '{}' doesn't exist" + .format(state)) + + +def _map_default_handler(state): + """Decorator to map a default handler to a state -- just makes adding + handlers a bit easier. + + :param state: the state that the handler is for. + :raises RuntimeError: if the state doesn't exist in + ALLOWED_DEFAULT_HANDLERS + """ + def wrapper(f): + if state in _default_handler_map: + raise RuntimeError( + "State '{}' can't have more than one default handler" + .format(state)) + if state not in ALLOWED_DEFAULT_HANDLERS: + raise RuntimeError( + "State '{} doesn't have a default handler????".format(state)) + _default_handler_map[state] = f + return f + return wrapper + + +@_map_default_handler('charm.installed') +def make_default_install_handler(): + + @reactive.when_not('charm.installed') + def default_install(): + """Provide a default install handler + + The instance automagically becomes the derived OpenStackCharm instance. + The kv() key charmers.openstack-release-version' is used to cache the + release being used for this charm. It is determined by the + default_select_release() function below, unless this is overriden by + the charm author + """ + unitdata.kv().unset(OPENSTACK_RELEASE_KEY) + OpenStackCharm.singleton.install() + reactive.set_state('charm.installed') + + +@_map_default_handler('charm.default-select-release') +def make_default_select_release_handler(): + """This handler is a bit more unusual, as it just sets the release selector + using the @register_os_release_selector decorator + """ + + @register_os_release_selector + def default_select_release(): + """Determine the release based on the python-keystonemiddleware that is + installed. + + Note that this function caches the release after the first install so + that it doesn't need to keep going and getting it from the package + information. + """ + release_version = unitdata.kv().get(OPENSTACK_RELEASE_KEY, None) + if release_version is None: + release_version = os_utils.os_release('python-keystonemiddleware') + unitdata.kv().set(OPENSTACK_RELEASE_KEY, release_version) + return release_version + + +@_map_default_handler('amqp.connected') +def make_default_amqp_connection_handler(): + + @reactive.when('amqp.connected') + def default_amqp_connection(amqp): + """Handle the default amqp connection. + + This requires that the charm implements get_amqp_credentials() to + provide a tuple of the (user, vhost) for the amqp server + """ + instance = OpenStackCharm.singleton + user, vhost = instance.get_amqp_credentials() + amqp.request_access(username=user, vhost=vhost) + instance.assess_status() + + +@_map_default_handler('shared-db.connected') +def make_default_setup_database_handler(): + + @reactive.when('shared-db.connected') + def default_setup_database(database): + """Handle the default database connection setup + + This requires that the charm implements get_database_setup() to provide + a list of dictionaries; + [{'database': ..., 'username': ..., 'hostname': ..., 'prefix': ...}] + + The prefix can be missing: it defaults to None. + """ + instance = OpenStackCharm.singleton + for db in instance.get_database_setup(): + database.configure(**db) + instance.assess_status() + + +@_map_default_handler('identity-service.connected') +def make_default_setup_endpoint_connection(): + + @reactive.when('identity-service.connected') + def default_setup_endpoint_connection(keystone): + """When the keystone interface connects, register this unit into the + catalog. This is the default handler, and calls on the charm class to + provide the endpoint information. If multiple endpoints are needed, + then a custom endpoint handler will be needed. + """ + instance = OpenStackCharm.singleton + keystone.register_endpoints(instance.service_type, + instance.region, + instance.public_url, + instance.internal_url, + instance.admin_url) + instance.assess_status() + + +@_map_default_handler('identity-service.available') +def make_setup_endpoint_available_handler(): + + @reactive.when('identity-service.available') + def default_setup_endpoint_available(keystone): + """When the identity-service interface is available, this default + handler switches on the SSL support. + """ + instance = OpenStackCharm.singleton + instance.configure_ssl(keystone) + instance.assess_status() + + +@_map_default_handler('config.changed') +def make_default_config_changed_handler(): + + @reactive.when('config.changed') + def default_config_changed(): + """Default handler for config.changed state from reactive. Just see if + our status has changed. This is just to clear any errors that may have + got stuck due to missing async handlers, etc. + """ + instance = OpenStackCharm.singleton + instance.config_changed() + instance.assess_status() + + +@_map_default_handler('upgrade-charm') +def make_default_upgrade_charm_handler(): + + @reactive.hook('upgrade-charm') + def default_upgrade_charm(): + """Default handler for the 'upgrade-charm' hook. + This calls the charm.singleton.upgrade_charm() function as a default. + """ + OpenStackCharm.singleton.upgrade_charm() + + +def default_render_configs(*interfaces): + """Default renderer for configurations. Really just a proxy for + OpenstackCharm.singleton.render_configs(..) with a call to update the + workload status afterwards. + + :params *interfaces: the list of interfaces to provide to the + render_configs() function + """ + instance = OpenStackCharm.singleton + instance.render_configs(interfaces) + instance.assess_status() + + +@_map_default_handler('update-status') +def make_default_update_status_handler(): + + @reactive.hook('update-status') + def default_update_status(): + """Default handler for update-status state. + Just call update status. + """ + instance = OpenStackCharm.singleton + hookenv.application_version_set(instance.application_version) + instance.assess_status() diff --git a/charms_openstack/charm/utils.py b/charms_openstack/charm/utils.py new file mode 100644 index 0000000..924e926 --- /dev/null +++ b/charms_openstack/charm/utils.py @@ -0,0 +1,23 @@ +import charmhelpers.fetch as fetch + + +# TODO: drop once charmhelpers releases a new version +# with this function in the fetch helper (> 0.9.1) +def get_upstream_version(package): + """Determine upstream version based on installed package + + @returns None (if not installed) or the upstream version + """ + import apt_pkg + cache = fetch.apt_cache() + try: + pkg = cache[package] + except: + # the package is unknown to the current apt cache. + return None + + if not pkg.current_ver: + # package is known, but no version is currently installed. + return None + + return apt_pkg.upstream_version(pkg.current_ver.ver_str) diff --git a/unit_tests/charms_openstack/__init__.py b/unit_tests/charms_openstack/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/unit_tests/charms_openstack/charm/__init__.py b/unit_tests/charms_openstack/charm/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/unit_tests/charms_openstack/charm/common.py b/unit_tests/charms_openstack/charm/common.py new file mode 100644 index 0000000..e261b3f --- /dev/null +++ b/unit_tests/charms_openstack/charm/common.py @@ -0,0 +1,59 @@ +import collections + +import charms_openstack.charm.core as chm_core +import charms_openstack.charm.classes as chm_classes + +# Helper class to make testing the charms possible + + +class MyAdapter(object): + + def __init__(self, interfaces, charm_instance=None): + self.interfaces = interfaces + + +# force the series to just contain my-series. +# NOTE that this is mocked out in the __init__.py for the unit_tests package +chm_core.os_utils.OPENSTACK_CODENAMES = collections.OrderedDict([ + ('2011.2', 'my-series'), +]) + + +class MyOpenStackCharm(chm_classes.OpenStackCharm): + + release = 'icehouse' + name = 'my-charm' + packages = ['p1', 'p2', 'p3', 'package-to-filter'] + version_package = 'p2' + api_ports = { + 'service1': { + 'public': 1, + 'int': 2, + }, + 'service2': { + 'public': 3, + }, + 'my-default-service': { + 'public': 1234, + 'admin': 2468, + 'int': 3579, + }, + } + service_type = 'my-service-type' + default_service = 'my-default-service' + restart_map = { + 'path1': ['s1'], + 'path2': ['s2'], + 'path3': ['s3'], + 'path4': ['s2', 's4'], + } + required_relations = [] + sync_cmd = ['my-sync-cmd', 'param1'] + services = ['my-default-service', 'my-second-service'] + adapters_class = MyAdapter + release_pkg = 'my-pkg' + + +class MyNextOpenStackCharm(MyOpenStackCharm): + + release = 'mitaka' diff --git a/unit_tests/charms_openstack/charm/test_classes.py b/unit_tests/charms_openstack/charm/test_classes.py new file mode 100644 index 0000000..874fbca --- /dev/null +++ b/unit_tests/charms_openstack/charm/test_classes.py @@ -0,0 +1,727 @@ +import base64 +import mock + +import unit_tests.utils as utils +from unit_tests.charms_openstack.charm.utils import BaseOpenStackCharmTest +from unit_tests.charms_openstack.charm.common import MyOpenStackCharm + +import charms_openstack.charm.classes as chm +import charms_openstack.charm.core as chm_core + +TEST_CONFIG = {'config': True} + + +class TestOpenStackCharm__init__(BaseOpenStackCharmTest): + # Just test the __init__() function, as it takes some params which do some + # initalisation. + + def setUp(self): + + class NoOp(object): + pass + + # bypass setting p the charm directly, as we want control over that. + super(TestOpenStackCharm__init__, self).setUp(NoOp, TEST_CONFIG) + + def test_empty_init_args(self): + target = chm.OpenStackCharm() + self.assertIsNone(target.release) + # we expect target.adapters_instance to not be None as + # target.adapters_class is not None as a default + self.assertIsNotNone(target.adapters_instance) + # from mocked hookenv.config() + self.assertEqual(target.config, TEST_CONFIG) + + def test_filled_init_args(self): + self.patch_object(chm_core, '_releases', new={}) + + class TestCharm(chm.OpenStackCharm): + release = 'mitaka' + adapters_class = mock.MagicMock() + + target = TestCharm('interfaces', 'config', 'release') + self.assertEqual(target.release, 'release') + self.assertEqual(target.config, 'config') + self.assertIsInstance(target.adapters_instance, mock.MagicMock) + TestCharm.adapters_class.assert_called_once_with( + 'interfaces', charm_instance=target) + + +class TestOpenStackCharm(BaseOpenStackCharmTest): + # Note that this only tests the OpenStackCharm() class, which has not very + # useful defaults for testing. In order to test all the code without too + # many mocks, a separate test dervied charm class is used below. + + def setUp(self): + super(TestOpenStackCharm, self).setUp(chm.OpenStackCharm, TEST_CONFIG) + + def test__init__(self): + # Note cls.setUpClass() creates an OpenStackCharm() instance + self.assertEqual(chm.hookenv.config(), TEST_CONFIG) + self.assertEqual(self.target.config, TEST_CONFIG) + # Note that we assume NO release unless given one. + self.assertEqual(self.target.release, None) + + def test_install(self): + # only tests that the default set_state is called + self.patch_target('set_state') + self.patch_object(chm_core.charmhelpers.fetch, + 'filter_installed_packages', + name='fip', + return_value=None) + self.patch_object(chm.subprocess, 'check_output', return_value=b'\n') + self.target.install() + self.target.set_state.assert_called_once_with('charmname-installed') + self.fip.assert_called_once_with([]) + + def test_all_packages(self): + self.assertEqual(self.target.packages, self.target.all_packages) + + def test_full_restart_map(self): + self.assertEqual(self.target.full_restart_map, self.target.restart_map) + + def test_set_state(self): + # tests that OpenStackCharm.set_state() calls set_state() global + self.patch_object(chm.reactive.bus, 'set_state') + self.target.set_state('hello') + self.set_state.assert_called_once_with('hello', None) + self.set_state.reset_mock() + self.target.set_state('hello', 'there') + self.set_state.assert_called_once_with('hello', 'there') + + def test_remove_state(self): + # tests that OpenStackCharm.remove_state() calls remove_state() global + self.patch_object(chm.reactive.bus, 'remove_state') + self.target.remove_state('hello') + self.remove_state.assert_called_once_with('hello') + + def test_configure_source(self): + self.patch_object(chm.os_utils, + 'configure_installation_source', + name='cis') + self.patch_object(chm_core.charmhelpers.fetch, 'apt_update') + self.patch_target('config', new={'openstack-origin': 'an-origin'}) + self.target.configure_source() + self.cis.assert_called_once_with('an-origin') + self.apt_update.assert_called_once_with(fatal=True) + + def test_region(self): + self.patch_target('config', new={'region': 'a-region'}) + self.assertEqual(self.target.region, 'a-region') + + def test_restart_on_change(self): + from collections import OrderedDict + hashs = OrderedDict([ + ('path1', 100), + ('path2', 200), + ('path3', 300), + ('path4', 400), + ]) + self.target.restart_map = { + 'path1': ['s1'], + 'path2': ['s2'], + 'path3': ['s3'], + 'path4': ['s2', 's4'], + } + self.patch_object(chm.ch_host, 'path_hash') + self.path_hash.side_effect = lambda x: hashs[x] + self.patch_object(chm.ch_host, 'service_stop') + self.patch_object(chm.ch_host, 'service_start') + # slightly awkard, in that we need to test a context manager + with self.target.restart_on_change(): + # test with no restarts + pass + self.assertEqual(self.service_stop.call_count, 0) + self.assertEqual(self.service_start.call_count, 0) + + with self.target.restart_on_change(): + # test with path1 and path3 restarts + for k in ['path1', 'path3']: + hashs[k] += 1 + self.assertEqual(self.service_stop.call_count, 2) + self.assertEqual(self.service_start.call_count, 2) + self.service_stop.assert_any_call('s1') + self.service_stop.assert_any_call('s3') + self.service_start.assert_any_call('s1') + self.service_start.assert_any_call('s3') + + # test with path2 and path4 and that s2 only gets restarted once + self.service_stop.reset_mock() + self.service_start.reset_mock() + with self.target.restart_on_change(): + for k in ['path2', 'path4']: + hashs[k] += 1 + self.assertEqual(self.service_stop.call_count, 2) + self.assertEqual(self.service_start.call_count, 2) + calls = [mock.call('s2'), mock.call('s4')] + self.service_stop.assert_has_calls(calls) + self.service_start.assert_has_calls(calls) + + def test_restart_all(self): + self.patch_object(chm.ch_host, 'service_restart') + self.patch_target('services', new=['s1', 's2']) + self.target.restart_all() + self.assertEqual(self.service_restart.call_args_list, + [mock.call('s1'), mock.call('s2')]) + + def test_db_sync_done(self): + self.patch_object(chm.hookenv, 'leader_get') + self.leader_get.return_value = True + self.assertTrue(self.target.db_sync_done()) + self.leader_get.return_value = False + self.assertFalse(self.target.db_sync_done()) + + def test_db_sync(self): + self.patch_object(chm.hookenv, 'is_leader') + self.patch_object(chm.hookenv, 'leader_get') + self.patch_object(chm.hookenv, 'leader_set') + self.patch_object(chm_core, 'subprocess', name='subprocess') + self.patch_target('restart_all') + # first check with leader_get returning True + self.leader_get.return_value = True + self.is_leader.return_value = True + self.target.db_sync() + self.leader_get.assert_called_once_with(attribute='db-sync-done') + self.subprocess.check_call.assert_not_called() + self.leader_set.assert_not_called() + # Now check with leader_get returning False + self.leader_get.reset_mock() + self.leader_get.return_value = False + self.target.sync_cmd = ['a', 'cmd'] + self.target.db_sync() + self.leader_get.assert_called_once_with(attribute='db-sync-done') + self.subprocess.check_call.assert_called_once_with(['a', 'cmd']) + self.leader_set.assert_called_once_with({'db-sync-done': True}) + # Now check with is_leader returning False + self.leader_set.reset_mock() + self.subprocess.check_call.reset_mock() + self.leader_get.return_value = True + self.is_leader.return_value = False + self.target.db_sync() + self.subprocess.check_call.assert_not_called() + self.leader_set.assert_not_called() + + +class TestMyOpenStackCharm(BaseOpenStackCharmTest): + + def setUp(self): + def make_open_stack_charm(): + return MyOpenStackCharm(['interface1', 'interface2']) + + super(TestMyOpenStackCharm, self).setUp(make_open_stack_charm, + TEST_CONFIG) + + def test_public_url(self): + self.patch_object(chm.os_ip, + 'canonical_url', + return_value='my-ip-address') + self.assertEqual(self.target.public_url, 'my-ip-address:1234') + self.canonical_url.assert_called_once_with(chm.os_ip.PUBLIC) + + def test_admin_url(self): + self.patch_object(chm.os_ip, + 'canonical_url', + return_value='my-ip-address') + self.assertEqual(self.target.admin_url, 'my-ip-address:2468') + self.canonical_url.assert_called_once_with(chm.os_ip.ADMIN) + + def test_internal_url(self): + self.patch_object(chm.os_ip, + 'canonical_url', + return_value='my-ip-address') + self.assertEqual(self.target.internal_url, 'my-ip-address:3579') + self.canonical_url.assert_called_once_with(chm.os_ip.INTERNAL) + + def test_application_version_unspecified(self): + self.patch_object(chm.os_utils, 'os_release') + self.patch_object(chm, 'get_upstream_version', + return_value='1.2.3') + self.target.version_package = None + self.assertEqual(self.target.application_version, '1.2.3') + self.get_upstream_version.assert_called_once_with('p1') + + def test_application_version_package(self): + self.patch_object(chm.os_utils, 'os_release') + self.patch_object(chm, 'get_upstream_version', + return_value='1.2.3') + self.assertEqual(self.target.application_version, '1.2.3') + self.get_upstream_version.assert_called_once_with('p2') + + def test_application_version_dfs(self): + self.patch_object(chm.os_utils, 'os_release', + return_value='mitaka') + self.patch_object(chm, 'get_upstream_version', + return_value=None) + self.assertEqual(self.target.application_version, 'mitaka') + self.get_upstream_version.assert_called_once_with('p2') + self.os_release.assert_called_once_with('p2') + + +class TestOpenStackAPICharm(BaseOpenStackCharmTest): + + def setUp(self): + super(TestOpenStackAPICharm, self).setUp(chm.OpenStackAPICharm, + TEST_CONFIG) + + def test_upgrade_charm(self): + self.patch_target('setup_token_cache') + self.patch_target('update_api_ports') + self.target.upgrade_charm() + self.target.setup_token_cache.assert_called_once_with() + + def test_install(self): + # Test set_state and configure_source are called + self.patch_target('set_state') + self.patch_target('configure_source') + self.patch_target('enable_memcache', return_value=False) + self.patch_object(chm_core.charmhelpers.fetch, + 'filter_installed_packages', + name='fip', + return_value=None) + self.patch_object(chm.subprocess, 'check_output', return_value=b'\n') + self.target.install() + # self.target.set_state.assert_called_once_with('charmname-installed') + self.target.configure_source.assert_called_once_with() + self.fip.assert_called_once_with([]) + + def test_setup_token_cache(self): + self.patch_target('token_cache_pkgs') + self.patch_target('install') + self.patch_object(chm_core.charmhelpers.fetch, + 'filter_installed_packages', + name='fip', + return_value=['memcached']) + self.target.setup_token_cache() + self.install.assert_called_once_with() + self.fip.return_value = [] + self.install.reset_mock() + self.target.setup_token_cache() + self.assertFalse(self.install.called) + + def test_enable_memcache(self): + self.assertFalse(self.target.enable_memcache(release='liberty')) + self.assertTrue(self.target.enable_memcache(release='newton')) + self.patch_target('config', new={'openstack-origin': 'distro'}) + self.patch_object(chm.os_utils, + 'get_os_codename_install_source', + name='gocis') + self.gocis.return_value = 'liberty' + self.assertFalse(self.target.enable_memcache()) + self.gocis.return_value = 'newton' + self.assertTrue(self.target.enable_memcache()) + + def test_token_cache_pkgs(self): + self.patch_target('enable_memcache') + self.enable_memcache.return_value = True + self.assertEqual(self.target.token_cache_pkgs(), ['memcached', + 'python-memcache']) + self.enable_memcache.return_value = False + self.assertEqual(self.target.token_cache_pkgs(), []) + + def test_get_amqp_credentials(self): + # verify that the instance throws an error if not overriden + with self.assertRaises(RuntimeError): + self.target.get_amqp_credentials() + + def test_get_database_setup(self): + # verify that the instance throws an error if not overriden + with self.assertRaises(RuntimeError): + self.target.get_database_setup() + + def test_all_packages(self): + self.patch_target('enable_memcache') + self.patch_target('packages', new=['pkg1', 'pkg2']) + self.enable_memcache.return_value = True + self.assertEqual(self.target.all_packages, + ['pkg1', 'pkg2', 'memcached', 'python-memcache']) + self.enable_memcache.return_value = False + self.assertEqual(self.target.all_packages, ['pkg1', 'pkg2']) + + def test_full_restart_map(self): + self.patch_target('enable_memcache') + base_restart_map = { + 'conf1': ['svc1'], + 'conf2': ['svc1']} + self.patch_target('restart_map', new=base_restart_map) + self.enable_memcache.return_value = True + self.assertEqual(self.target.full_restart_map, + {'conf1': ['svc1'], + 'conf2': ['svc1'], + '/etc/memcached.conf': ['memcached']}) + self.enable_memcache.return_value = False + self.assertEqual(self.target.full_restart_map, base_restart_map) + + +class TestHAOpenStackCharm(BaseOpenStackCharmTest): + # Note that this only tests the OpenStackCharm() class, which has not very + # useful defaults for testing. In order to test all the code without too + # many mocks, a separate test dervied charm class is used below. + + def setUp(self): + super(TestHAOpenStackCharm, self).setUp(chm.HAOpenStackCharm, + TEST_CONFIG) + + def test_all_packages(self): + self.patch_target('packages', new=['pkg1']) + self.patch_target('token_cache_pkgs', return_value=[]) + self.patch_target('haproxy_enabled', return_value=False) + self.patch_target('apache_enabled', return_value=False) + self.assertEqual(['pkg1'], self.target.all_packages) + self.token_cache_pkgs.return_value = ['memcache'] + self.haproxy_enabled.return_value = True + self.apache_enabled.return_value = True + self.assertEqual(['pkg1', 'memcache', 'haproxy', 'apache2'], + self.target.all_packages) + + def test_full_restart_map_disabled(self): + base_restart_map = { + 'conf1': ['svc1'], + 'conf2': ['svc1']} + self.patch_target('restart_map', new=base_restart_map) + self.patch_target('enable_memcache', return_value=False) + self.patch_target('haproxy_enabled', return_value=False) + self.patch_target('apache_enabled', return_value=False) + self.assertEqual(base_restart_map, self.target.full_restart_map) + + def test_full_restart_map_enabled(self): + base_restart_map = { + 'conf1': ['svc1'], + 'conf2': ['svc1']} + self.patch_target('restart_map', new=base_restart_map) + self.patch_target('enable_memcache', return_value=True) + self.patch_target('haproxy_enabled', return_value=True) + self.patch_target('apache_enabled', return_value=True) + self.assertEqual( + self.target.full_restart_map, + {'/etc/apache2/sites-available/openstack_https_frontend.conf': + ['apache2'], + '/etc/haproxy/haproxy.cfg': ['haproxy'], + '/etc/memcached.conf': ['memcached'], + 'conf1': ['svc1'], + 'conf2': ['svc1']}) + + def test_haproxy_enabled(self): + self.patch_target('ha_resources', new=['haproxy']) + self.assertTrue(self.target.haproxy_enabled()) + + def test__init__(self): + # Note cls.setUpClass() creates an OpenStackCharm() instance + self.assertEqual(chm.hookenv.config(), TEST_CONFIG) + self.assertEqual(self.target.config, TEST_CONFIG) + # Note that we assume NO release unless given one. + self.assertEqual(self.target.release, None) + + def test_configure_ha_resources(self): + interface_mock = mock.Mock() + self.patch_target('config', new={'vip_iface': 'ens12'}) + self.patch_target('ha_resources', new=['haproxy', 'vips']) + self.patch_target('_add_ha_vips_config') + self.patch_target('_add_ha_haproxy_config') + self.target.configure_ha_resources(interface_mock) + self._add_ha_vips_config.assert_called_once_with(interface_mock) + self._add_ha_haproxy_config.assert_called_once_with(interface_mock) + interface_mock.bind_resources.assert_called_once_with(iface='ens12') + + def test__add_ha_vips_config(self): + ifaces = { + 'vip1': 'eth1', + 'vip2': 'eth2'} + masks = { + 'vip1': 'netmask1', + 'vip2': 'netmask2'} + interface_mock = mock.Mock() + self.patch_target('name', new='myservice') + self.patch_target('config', new={'vip': 'vip1 vip2'}) + self.patch_object(chm.ch_ip, 'get_iface_for_address') + self.get_iface_for_address.side_effect = lambda x: ifaces[x] + self.patch_object(chm.ch_ip, 'get_netmask_for_address') + self.get_netmask_for_address.side_effect = lambda x: masks[x] + self.target._add_ha_vips_config(interface_mock) + calls = [ + mock.call('myservice', 'vip1', 'eth1', 'netmask1'), + mock.call('myservice', 'vip2', 'eth2', 'netmask2')] + interface_mock.add_vip.assert_has_calls(calls) + + def test__add_ha_vips_config_fallback(self): + config = { + 'vip_cidr': 'user_cidr', + 'vip_iface': 'user_iface', + 'vip': 'vip1 vip2'} + interface_mock = mock.Mock() + self.patch_target('name', new='myservice') + self.patch_target('config', new=config) + self.patch_object(chm.ch_ip, 'get_iface_for_address') + self.patch_object(chm.ch_ip, 'get_netmask_for_address') + self.get_iface_for_address.return_value = None + self.get_netmask_for_address.return_value = None + self.target._add_ha_vips_config(interface_mock) + calls = [ + mock.call('myservice', 'vip1', 'user_iface', 'user_cidr'), + mock.call('myservice', 'vip2', 'user_iface', 'user_cidr')] + interface_mock.add_vip.assert_has_calls(calls) + + def test__add_ha_haproxy_config(self): + self.patch_target('name', new='myservice') + interface_mock = mock.Mock() + self.target._add_ha_haproxy_config(interface_mock) + interface_mock.add_init_service.assert_called_once_with( + 'myservice', + 'haproxy') + + def test_set_haproxy_stat_password(self): + self.patch_object(chm.reactive.bus, 'get_state') + self.patch_object(chm.reactive.bus, 'set_state') + self.get_state.return_value = None + self.target.set_haproxy_stat_password() + self.set_state.assert_called_once_with('haproxy.stat.password', + mock.ANY) + + def test_hacharm_all_packages_enabled(self): + self.patch_target('enable_memcache', return_value=False) + self.patch_target('haproxy_enabled', return_value=True) + self.assertTrue('haproxy' in self.target.all_packages) + + def test_hacharm_all_packages_disabled(self): + self.patch_target('enable_memcache', return_value=False) + self.patch_target('haproxy_enabled', return_value=False) + self.assertFalse('haproxy' in self.target.all_packages) + + def test_hacharm_full_restart_map(self): + self.patch_target('enable_memcache', return_value=False) + self.patch_target('haproxy_enabled', return_value=True) + self.assertTrue( + self.target.full_restart_map.get( + '/etc/haproxy/haproxy.cfg', False)) + + def test_enable_apache_ssl_vhost(self): + self.patch_object(chm.os.path, 'exists', return_value=True) + self.patch_object(chm.subprocess, 'call', return_value=1) + self.patch_object(chm.subprocess, 'check_call') + self.target.enable_apache_ssl_vhost() + self.check_call.assert_called_once_with( + ['a2ensite', 'openstack_https_frontend']) + self.check_call.reset_mock() + self.patch_object(chm.subprocess, 'call', return_value=0) + self.target.enable_apache_ssl_vhost() + self.assertFalse(self.check_call.called) + + def test_enable_apache_modules(self): + apache_mods = { + 'ssl': 0, + 'proxy': 0, + 'proxy_http': 1} + self.patch_object(chm.ch_host, 'service_restart') + self.patch_object(chm.subprocess, 'check_call') + self.patch_object( + chm.subprocess, 'call', + new=lambda x: apache_mods[x.pop()]) + self.target.enable_apache_modules() + self.check_call.assert_called_once_with( + ['a2enmod', 'proxy_http']) + self.service_restart.assert_called_once_with('apache2') + + def test_configure_cert(self): + self.patch_object(chm.ch_host, 'mkdir') + self.patch_object(chm.ch_host, 'write_file') + self.target.configure_cert('mycert', 'mykey', cn='mycn') + self.mkdir.assert_called_once_with(path='/etc/apache2/ssl/charmname') + calls = [ + mock.call( + path='/etc/apache2/ssl/charmname/cert_mycn', + content=b'mycert'), + mock.call( + path='/etc/apache2/ssl/charmname/key_mycn', + content=b'mykey')] + self.write_file.assert_has_calls(calls) + self.write_file.reset_mock() + self.patch_object(chm.os_ip, 'resolve_address', 'addr') + self.target.configure_cert('mycert', 'mykey') + calls = [ + mock.call( + path='/etc/apache2/ssl/charmname/cert_addr', + content=b'mycert'), + mock.call( + path='/etc/apache2/ssl/charmname/key_addr', + content=b'mykey')] + self.write_file.assert_has_calls(calls) + + def test_get_local_addresses(self): + self.patch_object(chm.os_utils, 'get_host_ip', return_value='privaddr') + self.patch_object(chm.os_ip, 'resolve_address') + addresses = { + 'admin': 'admin_addr', + 'int': 'internal_addr', + 'public': 'public_addr'} + self.resolve_address.side_effect = \ + lambda endpoint_type=None: addresses[endpoint_type] + self.assertEqual( + self.target.get_local_addresses(), + ['admin_addr', 'internal_addr', 'privaddr', 'public_addr']) + + def test_get_certs_and_keys(self): + config = { + 'ssl_key': base64.b64encode(b'key'), + 'ssl_cert': base64.b64encode(b'cert'), + 'ssl_ca': base64.b64encode(b'ca')} + self.patch_target('config', new=config) + self.assertEqual( + self.target.get_certs_and_keys(), + [{'key': 'key', 'cert': 'cert', 'ca': 'ca', 'cn': None}]) + + def test_get_certs_and_keys_ks_interface(self): + class KSInterface(object): + def get_ssl_key(self, key): + keys = { + 'int_addr': 'int_key', + 'priv_addr': 'priv_key', + 'pub_addr': 'pub_key', + 'admin_addr': 'admin_key'} + return keys[key] + + def get_ssl_cert(self, key): + certs = { + 'int_addr': 'int_cert', + 'priv_addr': 'priv_cert', + 'pub_addr': 'pub_cert', + 'admin_addr': 'admin_cert'} + return certs[key] + + def get_ssl_ca(self): + return 'ca' + + self.patch_target( + 'get_local_addresses', + return_value=['int_addr', 'priv_addr', 'pub_addr', 'admin_addr']) + expect = [ + { + 'ca': 'ca', + 'cert': 'int_cert', + 'cn': 'int_addr', + 'key': 'int_key'}, + { + 'ca': 'ca', + 'cert': 'priv_cert', + 'cn': 'priv_addr', + 'key': 'priv_key'}, + { + 'ca': 'ca', + 'cert': 'pub_cert', + 'cn': 'pub_addr', + 'key': 'pub_key'}, + { + 'ca': 'ca', + 'cert': 'admin_cert', + 'cn': 'admin_addr', + 'key': 'admin_key'}] + + self.assertEqual( + self.target.get_certs_and_keys(keystone_interface=KSInterface()), + expect) + + def test_config_defined_certs_and_keys(self): + # test that the cached parameters do what we expect + config = { + 'ssl_key': base64.b64encode(b'confkey'), + 'ssl_cert': base64.b64encode(b'confcert'), + 'ssl_ca': base64.b64encode(b'confca')} + self.patch_target('config', new=config) + self.assertEqual(self.target.config_defined_ssl_key, b'confkey') + self.assertEqual(self.target.config_defined_ssl_cert, b'confcert') + self.assertEqual(self.target.config_defined_ssl_ca, b'confca') + + def test_configure_ssl(self): + ssl_objs = [ + { + 'cert': 'cert1', + 'key': 'key1', + 'ca': 'ca1', + 'cn': 'cn1'}, + { + 'cert': 'cert2', + 'key': 'key2', + 'ca': 'ca2', + 'cn': 'cn2'}] + self.patch_target('get_certs_and_keys', return_value=ssl_objs) + self.patch_target('configure_apache') + self.patch_target('configure_cert') + self.patch_target('configure_ca') + self.patch_object(chm.reactive.bus, 'set_state') + self.patch_object(chm.reactive.RelationBase, 'from_state', + return_value=None) + self.target.configure_ssl() + cert_calls = [ + mock.call('cert1', 'key1', cn='cn1'), + mock.call('cert2', 'key2', cn='cn2')] + ca_calls = [ + mock.call('ca1'), + mock.call('ca2')] + self.configure_cert.assert_has_calls(cert_calls) + self.configure_ca.assert_has_calls(ca_calls) + self.configure_apache.assert_called_once_with() + self.set_state.assert_called_once_with('ssl.enabled', True) + + def test_configure_ssl_off(self): + self.patch_target('get_certs_and_keys', return_value=[]) + self.patch_object(chm.reactive.bus, 'set_state') + self.patch_object(chm.reactive.RelationBase, 'from_state', + return_value=None) + self.target.configure_ssl() + self.set_state.assert_called_once_with('ssl.enabled', False) + + def test_configure_ssl_rabbit(self): + self.patch_target('get_certs_and_keys', return_value=[]) + self.patch_target('configure_rabbit_cert') + self.patch_object(chm.reactive.bus, 'set_state') + self.patch_object(chm.reactive.RelationBase, 'from_state', + return_value='ssl_int') + self.target.configure_ssl() + self.set_state.assert_called_once_with('ssl.enabled', False) + self.configure_rabbit_cert.assert_called_once_with('ssl_int') + + def test_configure_rabbit_cert(self): + rabbit_int_mock = mock.MagicMock() + rabbit_int_mock.get_ssl_cert.return_value = 'rabbit_cert' + self.patch_object(chm.os.path, 'exists', return_value=True) + self.patch_object(chm.os, 'mkdir') + self.patch_object(chm.hookenv, 'service_name', return_value='svc1') + with utils.patch_open() as (mock_open, mock_file): + self.target.configure_rabbit_cert(rabbit_int_mock) + mock_open.assert_called_with( + '/var/lib/charm/svc1/rabbit-client-ca.pem', + 'w') + mock_file.write.assert_called_with('rabbit_cert') + + def test_configure_ca(self): + self.patch_target('run_update_certs') + with utils.patch_open() as (mock_open, mock_file): + self.target.configure_ca('myca') + mock_open.assert_called_with( + '/usr/local/share/ca-certificates/keystone_juju_ca_cert.crt', + 'w') + mock_file.write.assert_called_with('myca') + + def test_run_update_certs(self): + self.patch_object(chm.subprocess, 'check_call') + self.target.run_update_certs() + self.check_call.assert_called_once_with( + ['update-ca-certificates', '--fresh']) + + def test_update_central_cacerts(self): + self.patch_target('run_update_certs') + change_hashes = ['hash1', 'hash2'] + nochange_hashes = ['hash1', 'hash1'] + + def fake_hash(hash_dict): + def fake_hash_inner(filename): + return hash_dict.pop() + return fake_hash_inner + self.patch_object(chm.ch_host, 'path_hash') + self.path_hash.side_effect = fake_hash(change_hashes) + with self.target.update_central_cacerts(['file1']): + pass + self.run_update_certs.assert_called_with() + self.run_update_certs.reset_mock() + self.path_hash.side_effect = fake_hash(nochange_hashes) + with self.target.update_central_cacerts(['file1']): + pass + self.assertFalse(self.run_update_certs.called) diff --git a/unit_tests/charms_openstack/charm/test_core.py b/unit_tests/charms_openstack/charm/test_core.py new file mode 100644 index 0000000..b0f7135 --- /dev/null +++ b/unit_tests/charms_openstack/charm/test_core.py @@ -0,0 +1,649 @@ +import collections +import mock +import unittest + +import charms_openstack.charm.core as chm_core + +from unit_tests.charms_openstack.charm.utils import BaseOpenStackCharmTest +from unit_tests.charms_openstack.charm.common import ( + MyAdapter, + MyOpenStackCharm, + MyNextOpenStackCharm, +) + +import unit_tests.utils as utils + +TEST_CONFIG = {'config': True} + + +class TestRegisterOSReleaseSelector(unittest.TestCase): + + def test_register(self): + save_rsf = chm_core._release_selector_function + chm_core._release_selector_function = None + + @chm_core.register_os_release_selector + def test_func(): + pass + + self.assertEqual(chm_core._release_selector_function, test_func) + chm_core._release_selector_function = save_rsf + + def test_cant_register_more_than_once(self): + save_rsf = chm_core._release_selector_function + chm_core._release_selector_function = None + + @chm_core.register_os_release_selector + def test_func1(): + pass + + with self.assertRaises(RuntimeError): + @chm_core.register_os_release_selector + def test_func2(): + pass + + self.assertEqual(chm_core._release_selector_function, test_func1) + chm_core._release_selector_function = save_rsf + + +class TestBaseOpenStackCharmMeta(BaseOpenStackCharmTest): + + def setUp(self): + super().setUp(chm_core.BaseOpenStackCharm, TEST_CONFIG) + + def test_register_classes(self): + self.patch_object(chm_core, '_releases', new={}) + + class TestC1(chm_core.BaseOpenStackCharm): + release = 'liberty' + + class TestC2(chm_core.BaseOpenStackCharm): + release = 'mitaka' + + self.assertTrue('liberty' in chm_core._releases.keys()) + self.assertTrue('mitaka' in chm_core._releases.keys()) + self.assertEqual(chm_core._releases['liberty'], TestC1) + self.assertEqual(chm_core._releases['mitaka'], TestC2) + + def test_register_unknown_series(self): + self.patch_object(chm_core, '_releases', new={}) + with self.assertRaises(RuntimeError): + class TestC1(chm_core.BaseOpenStackCharm): + release = 'unknown' + + def test_register_repeated_series(self): + self.patch_object(chm_core, '_releases', new={}) + with self.assertRaises(RuntimeError): + class TestC1(chm_core.BaseOpenStackCharm): + release = 'liberty' + + class TestC2(chm_core.BaseOpenStackCharm): + release = 'liberty' + + +class TestFunctions(BaseOpenStackCharmTest): + + def setUp(self): + super().setUp(chm_core.BaseOpenStackCharm, TEST_CONFIG) + self.patch_object(chm_core, '_releases', new={}) + + class TestC1(chm_core.BaseOpenStackCharm): + release = 'icehouse' + + class TestC2(chm_core.BaseOpenStackCharm): + release = 'kilo' + + class TestC3(chm_core.BaseOpenStackCharm): + release = 'mitaka' + + self.C1, self.C2, self.C3 = TestC1, TestC2, TestC3 + + def test_get_exact(self): + self.assertTrue( + isinstance(chm_core.get_charm_instance(release='icehouse'), + self.C1)) + self.assertTrue( + isinstance(chm_core.get_charm_instance(release='mitaka'), self.C3)) + + def test_get_inbetween(self): + self.assertTrue( + isinstance(chm_core.get_charm_instance(release='juno'), self.C1)) + + def test_fail_too_early_series(self): + with self.assertRaises(RuntimeError): + chm_core.get_charm_instance(release='havana') + + def test_get_default_release(self): + # TODO this may be the wrong logic. Assume latest release if no + # release is passed? + self.assertIsInstance(chm_core.get_charm_instance(), self.C3) + + def test_optional_interfaces(self): + self.patch_object(chm_core.reactive, + 'RelationBase', + name='relation_base') + self.relation_base.from_state.side_effect = ['x', None, 'z'] + r = chm_core.optional_interfaces( + ('a', 'b', 'c'), 'any', 'old', 'thing') + self.assertEqual(r, ('a', 'b', 'c', 'x', 'z')) + self.relation_base.from_state.assert_has_calls( + [mock.call('any'), mock.call('old'), mock.call('thing')]) + + +class TestProvideCharmInstance(utils.BaseTestCase): + + def test_provide_charm_instance_as_decorator(self): + self.patch_object(chm_core, 'BaseOpenStackCharm', name='charm') + self.charm.singleton = 'the-charm' + + @chm_core.provide_charm_instance + def the_handler(charm_instance, *args): + self.assertEqual(charm_instance, 'the-charm') + self.assertEqual(args, (1, 2, 3)) + + the_handler(1, 2, 3) + + def test_provide_charm_instance_as_context_manager(self): + self.patch_object(chm_core, 'BaseOpenStackCharm', name='charm') + self.charm.singleton = 'the-charm' + + with chm_core.provide_charm_instance() as charm: + self.assertEqual(charm, 'the-charm') + + +class TestBaseOpenStackCharmAssessStatus(BaseOpenStackCharmTest): + + def setUp(self): + def make_open_stack_charm(): + return MyOpenStackCharm(['interface1', 'interface2']) + + super().setUp(make_open_stack_charm, TEST_CONFIG) + + def test_deferred_assess_status(self): + self.patch_object(chm_core.hookenv, 'atexit') + # s = self.target.singleton + s = self.target + self.patch_target('_assess_status') + s.assess_status() + self._assess_status.assert_not_called() + self.atexit.assert_called_once_with(mock.ANY) + self.atexit.reset_mock() + s.assess_status() + self.atexit.assert_not_called() + self._assess_status.assert_not_called() + + def test_assess_status_active(self): + self.patch_object(chm_core.hookenv, 'status_set') + # disable all of the check functions + self.patch_target('check_if_paused', return_value=(None, None)) + self.patch_target('check_interfaces', return_value=(None, None)) + self.patch_target('custom_assess_status_check', + return_value=(None, None)) + self.patch_target('check_services_running', return_value=(None, None)) + self.target._assess_status() + self.status_set.assert_called_once_with('active', 'Unit is ready') + # check all the check functions got called + self.check_if_paused.assert_called_once_with() + self.check_interfaces.assert_called_once_with() + self.custom_assess_status_check.assert_called_once_with() + self.check_services_running.assert_called_once_with() + + def test_assess_status_paused(self): + self.patch_object(chm_core.hookenv, 'status_set') + # patch out _ows_check_if_paused + self.patch_object(chm_core.os_utils, '_ows_check_if_paused', + return_value=('paused', '123')) + self.target._assess_status() + self.status_set.assert_called_once_with('paused', '123') + self._ows_check_if_paused.assert_called_once_with( + services=self.target.services, + ports=[1, 2, 3, 1234, 2468, 3579]) + + def test_states_to_check(self): + self.patch_target('required_relations', new=['rel1', 'rel2']) + states = self.target.states_to_check() + self.assertEqual( + states, + { + 'rel1': [ + ('rel1.connected', 'blocked', "'rel1' missing"), + ('rel1.available', 'waiting', "'rel1' incomplete") + ], + 'rel2': [ + ('rel2.connected', 'blocked', "'rel2' missing"), + ('rel2.available', 'waiting', "'rel2' incomplete") + ] + }) + # test override feature of target.states_to_check() + states = self.target.states_to_check(required_relations=['rel3']) + self.assertEqual( + states, + { + 'rel3': [ + ('rel3.connected', 'blocked', "'rel3' missing"), + ('rel3.available', 'waiting', "'rel3' incomplete") + ], + }) + + def test_assess_status_check_interfaces(self): + self.patch_object(chm_core.hookenv, 'status_set') + self.patch_target('check_if_paused', return_value=(None, None)) + # first check it returns None, None if there are no states + with mock.patch.object(self.target, + 'states_to_check', + return_value={}): + self.assertEqual(self.target.check_interfaces(), (None, None)) + # next check that we get back the states we think we should + self.patch_object(chm_core.reactive.bus, + 'get_states', + return_value={'rel1.connected': 1, }) + self.patch_target('required_relations', new=['rel1', 'rel2']) + + def my_compare(x, y): + if x is None: + x = 'unknown' + if x <= y: + return x + return y + + self.patch_object(chm_core.os_utils, 'workload_state_compare', + new=my_compare) + self.assertEqual(self.target.check_interfaces(), + ('blocked', "'rel1' incomplete, 'rel2' missing")) + # check that the assess_status give the same result + self.target._assess_status() + self.status_set.assert_called_once_with( + 'blocked', "'rel1' incomplete, 'rel2' missing") + + # Now check it returns None, None if all states are available + self.get_states.return_value = { + 'rel1.connected': 1, + 'rel1.available': 2, + 'rel2.connected': 3, + 'rel2.available': 4, + } + self.assertEqual(self.target.check_interfaces(), (None, None)) + + def test_check_assess_status_check_services_running(self): + # verify that the function calls _ows_check_services_running() with the + # valid information + self.patch_object(chm_core.os_utils, '_ows_check_services_running', + return_value=('active', 'that')) + status, message = self.target.check_services_running() + self.assertEqual((status, message), ('active', 'that')) + self._ows_check_services_running.assert_called_once_with( + services=['my-default-service', 'my-second-service'], + ports=[1, 2, 3, 1234, 2468, 3579]) + + def test_check_ports_to_check(self): + ports = { + 's1': {'k1': 3, 'k2': 4, 'k3': 5}, + 's2': {'k4': 6, 'k5': 1, 'k6': 2}, + 's3': {'k2': 4, 'k5': 1}, + } + self.assertEqual(self.target.ports_to_check(ports), + [1, 2, 3, 4, 5, 6]) + + +class TestMyOpenStackCharm(BaseOpenStackCharmTest): + + def setUp(self): + def make_open_stack_charm(): + return MyOpenStackCharm(['interface1', 'interface2']) + + super(TestMyOpenStackCharm, self).setUp(make_open_stack_charm, + TEST_CONFIG) + + def test_singleton(self): + # because we have two releases, we expect this to be the latter. + # e.g. MyNextOpenStackCharm + s = self.target.singleton + self.assertEqual(s.__class__.release, 'mitaka') + self.assertIsInstance(s, MyOpenStackCharm) + # should also be the second one, as it's the latest + self.assertIsInstance(s, MyNextOpenStackCharm) + self.assertIsInstance(MyOpenStackCharm.singleton, + MyOpenStackCharm) + self.assertIsInstance(chm_core.BaseOpenStackCharm.singleton, + MyOpenStackCharm) + self.assertEqual(s, chm_core.BaseOpenStackCharm.singleton) + # Note that get_charm_instance() returns NEW instance each time. + self.assertNotEqual(s, chm_core.get_charm_instance()) + # now clear out the singleton and make sure we get the first one using + # a release function + rsf_save = chm_core._release_selector_function + chm_core._release_selector_function = None + + @chm_core.register_os_release_selector + def selector(): + return 'icehouse' + + # This should choose the icehouse version instead of the mitaka version + chm_core._singleton = None + s = self.target.singleton + self.assertEqual(s.release, 'icehouse') + self.assertEqual(s.__class__.release, 'icehouse') + self.assertFalse(isinstance(s, MyNextOpenStackCharm)) + chm_core._release_selector_function = rsf_save + + def test_install(self): + # tests that the packages are filtered before installation + # self.patch_target('set_state') + self.patch_object(chm_core.charmhelpers.fetch, + 'filter_installed_packages', + return_value=None, + name='fip') + self.fip.side_effect = lambda x: ['p1', 'p2'] + self.patch_object(chm_core.hookenv, 'status_set') + self.patch_object(chm_core.hookenv, 'apt_install') + self.patch_object(chm_core.subprocess, + 'check_output', return_value=b'\n') + self.target.install() + # TODO: remove next commented line as we don't set this state anymore + # self.target.set_state.assert_called_once_with('my-charm-installed') + self.fip.assert_called_once_with(self.target.packages) + self.status_set.assert_has_calls([ + mock.call('maintenance', 'Installing packages'), + mock.call('maintenance', + 'Installation complete - awaiting next status')]) + + def test_api_port(self): + self.assertEqual(self.target.api_port('service1'), 1) + self.assertEqual(self.target.api_port('service1', + chm_core.os_ip.PUBLIC), 1) + self.assertEqual(self.target.api_port('service2'), 3) + with self.assertRaises(KeyError): + self.target.api_port('service3') + with self.assertRaises(KeyError): + self.target.api_port('service2', chm_core.os_ip.INTERNAL) + + def test_update_api_ports(self): + self.patch_object(chm_core.hookenv, 'open_port') + self.patch_object(chm_core.hookenv, 'close_port') + self.patch_object(chm_core.subprocess, + 'check_output', return_value=b'\n') + self.target.api_ports = { + 'api': { + 'public': 1, + 'internal': 2, + 'admin': 3, + }, + } + test_ports = [4, 5, 6] + self.target.update_api_ports(test_ports) + calls = [mock.call(4), mock.call(5), mock.call(6)] + self.open_port.assert_has_calls(calls) + self.open_port.reset_mock() + self.target.update_api_ports() + calls = [mock.call(1), mock.call(2), mock.call(3)] + self.open_port.assert_has_calls(calls) + self.close_port.assert_not_called() + # now check that it doesn't open ports already open and closes ports + # that should be closed + self.open_port.reset_mock() + self.close_port.reset_mock() + self.check_output.return_value = b"1/tcp\n2/tcp\n3/udp\n4/tcp\n" + # port 3 should be opened, port 4 should be closed. + open_calls = [mock.call(3)] + close_calls = [mock.call(4)] + self.target.update_api_ports() + self.open_port.asset_has_calls(open_calls) + self.close_port.assert_has_calls(close_calls) + + def test_opened_ports(self): + self.patch_object(chm_core.subprocess, 'check_output') + self.check_output.return_value = b'\n' + self.assertEqual([], self.target.opened_ports()) + self.check_output.return_value = b'1/tcp\n2/tcp\n3/udp\n4/tcp\n5/udp\n' + self.assertEqual(['1', '2', '4'], self.target.opened_ports()) + self.assertEqual(['1', '2', '4'], + self.target.opened_ports(protocol='TCP')) + self.assertEqual(['3', '5'], self.target.opened_ports(protocol='udp')) + self.assertEqual(['1/tcp', '2/tcp', '3/udp', '4/tcp', '5/udp'], + self.target.opened_ports(protocol=None)) + self.assertEqual([], self.target.opened_ports(protocol='other')) + + def test_render_all_configs(self): + self.patch_target('render_configs') + self.target.render_all_configs() + self.assertEqual(self.render_configs.call_count, 1) + args = self.render_configs.call_args_list[0][0][0] + self.assertEqual(['path1', 'path2', 'path3', 'path4'], + sorted(args)) + + def test_render_configs(self): + # give us a way to check that the context manager was called. + from contextlib import contextmanager + d = [0] + + @contextmanager + def fake_restart_on_change(): + d[0] += 1 + yield + + self.patch_target('restart_on_change', new=fake_restart_on_change) + self.patch_object(chm_core.charmhelpers.core.templating, 'render') + self.patch_object(chm_core.os_templating, + 'get_loader', + return_value='my-loader') + # self.patch_target('adapter_instance', new='my-adapter') + self.target.render_configs(['path1']) + self.assertEqual(d[0], 1) + self.render.assert_called_once_with( + source='path1', + template_loader='my-loader', + target='path1', + context=mock.ANY) + # assert the context was an MyAdapter instance. + context = self.render.call_args_list[0][1]['context'] + assert isinstance(context, MyAdapter) + self.assertEqual(context.interfaces, ['interface1', 'interface2']) + + def test_render_configs_singleton_render_with_interfaces(self): + self.patch_object(chm_core.charmhelpers.core.templating, 'render') + self.patch_object(chm_core.os_templating, + 'get_loader', + return_value='my-loader') + # also patch the cls.adapters_class to ensure that it is called with + # the target. + self.patch_object(self.target.singleton, 'adapters_class', + return_value='the-context') + + self.target.singleton.render_with_interfaces( + ['interface1', 'interface2']) + + self.adapters_class.assert_called_once_with( + ['interface1', 'interface2'], charm_instance=self.target.singleton) + + calls = [ + mock.call( + source='path1', + template_loader='my-loader', + target='path1', + context=mock.ANY), + mock.call( + source='path2', + template_loader='my-loader', + target='path2', + context=mock.ANY), + mock.call( + source='path3', + template_loader='my-loader', + target='path3', + context=mock.ANY), + mock.call( + source='path4', + template_loader='my-loader', + target='path4', + context=mock.ANY), + ] + self.render.assert_has_calls(calls, any_order=True) + # Assert that None was not passed to render via the context kwarg + for call in self.render.call_args_list: + self.assertTrue(call[1]['context']) + + def test_render_configs_singleton_render_with_old_style_interfaces(self): + # Test for fix to Bug #1623917 + self.patch_object(chm_core.charmhelpers.core.templating, 'render') + self.patch_object(chm_core.os_templating, + 'get_loader', + return_value='my-loader') + + class OldSkoolAdapter(object): + def __init__(self, interfaces): + pass + self.patch_object(self.target.singleton, 'adapters_class') + self.adapters_class.side_effect = OldSkoolAdapter + + self.target.singleton.render_with_interfaces( + ['interface1', 'interface2']) + + adapter_calls = [ + mock.call( + ['interface1', 'interface2'], + charm_instance=self.target.singleton), + mock.call( + ['interface1', 'interface2'])] + self.adapters_class.assert_has_calls(adapter_calls) + + calls = [ + mock.call( + source='path1', + template_loader='my-loader', + target='path1', + context=mock.ANY), + mock.call( + source='path2', + template_loader='my-loader', + target='path2', + context=mock.ANY), + mock.call( + source='path3', + template_loader='my-loader', + target='path3', + context=mock.ANY), + mock.call( + source='path4', + template_loader='my-loader', + target='path4', + context=mock.ANY), + ] + self.render.assert_has_calls(calls, any_order=True) + # Assert that None was not passed to render via the context kwarg + for call in self.render.call_args_list: + self.assertTrue(call[1]['context']) + + def test_get_os_codename_package(self): + codenames = { + 'testpkg': collections.OrderedDict([ + ('2', 'mitaka'), + ('3', 'newton'), + ('4', 'ocata'), ])} + self.patch_object(chm_core.charmhelpers.fetch, 'apt_cache') + pkg_mock = mock.MagicMock() + self.apt_cache.return_value = { + 'testpkg': pkg_mock} + self.patch_object(chm_core.apt, 'upstream_version') + self.upstream_version.return_value = '3.0.0~b1' + self.assertEqual( + chm_core.BaseOpenStackCharm.get_os_codename_package( + 'testpkg', codenames), + 'newton') + # Test non-fatal fail + self.assertEqual( + chm_core.BaseOpenStackCharm.get_os_codename_package( + 'unknownpkg', codenames, fatal=False), + None) + # Test fatal fail + with self.assertRaises(Exception): + chm_core.BaseOpenStackCharm.get_os_codename_package( + 'unknownpkg', codenames, fatal=True) + + def test_get_os_version_package(self): + self.patch_target('package_codenames') + self.patch_target('get_os_codename_package', + return_value='my-series') + self.assertEqual( + self.target.get_os_version_package('testpkg'), + '2011.2') + # Test unknown codename + self.patch_target('get_os_codename_package', + return_value='unknown-series') + self.assertEqual(self.target.get_os_version_package('testpkg'), None) + + def test_openstack_upgrade_available(self): + self.patch_target('get_os_version_package') + self.patch_object(chm_core.os_utils, 'get_os_version_install_source') + self.patch_object(chm_core, 'apt') + self.patch_target('config', + new={'openstack-origin': 'cloud:natty-folsom'}) + self.get_os_version_package.return_value = 2 + self.get_os_version_install_source.return_value = 3 + self.target.openstack_upgrade_available('testpkg') + self.apt.version_compare.assert_called_once_with(3, 2) + + def test_upgrade_if_available(self): + self.patch_target('openstack_upgrade_available') + self.patch_object(chm_core.hookenv, 'status_set') + self.patch_target('do_openstack_pkg_upgrade') + self.patch_target('do_openstack_upgrade_config_render') + self.patch_target('do_openstack_upgrade_db_migration') + # Test no upgrade avaialble + self.openstack_upgrade_available.return_value = False + self.target.upgrade_if_available('int_list') + self.assertFalse(self.status_set.called) + self.assertFalse(self.do_openstack_pkg_upgrade.called) + self.assertFalse(self.do_openstack_upgrade_config_render.called) + self.assertFalse(self.do_openstack_upgrade_db_migration.called) + # Test upgrade avaialble + self.openstack_upgrade_available.return_value = True + self.target.upgrade_if_available('int_list') + self.status_set.assert_called_once_with('maintenance', + 'Running openstack upgrade') + self.do_openstack_pkg_upgrade.assert_called_once_with() + self.do_openstack_upgrade_config_render.assert_called_once_with( + 'int_list') + self.do_openstack_upgrade_db_migration.assert_called_once_with() + + def test_do_openstack_pkg_upgrade(self): + self.patch_target('config', + new={'openstack-origin': 'cloud:natty-kilo'}) + self.patch_object(chm_core.os_utils, 'get_os_codename_install_source') + self.patch_object(chm_core.hookenv, 'log') + self.patch_object(chm_core.os_utils, 'configure_installation_source') + self.patch_object(chm_core.charmhelpers.fetch, 'apt_update') + self.patch_object(chm_core.charmhelpers.fetch, 'apt_upgrade') + self.patch_object(chm_core.charmhelpers.fetch, 'apt_install') + self.target.do_openstack_pkg_upgrade() + self.configure_installation_source.assert_called_once_with( + 'cloud:natty-kilo') + self.apt_update.assert_called_once_with() + self.apt_upgrade.assert_called_once_with( + dist=True, fatal=True, + options=[ + '--option', 'Dpkg::Options::=--force-confnew', '--option', + 'Dpkg::Options::=--force-confdef']) + self.apt_install.assert_called_once_with( + packages=['p1', 'p2', 'p3', 'package-to-filter'], + options=[ + '--option', 'Dpkg::Options::=--force-confnew', '--option', + 'Dpkg::Options::=--force-confdef'], + fatal=True) + + def test_do_openstack_upgrade_config_render(self): + self.patch_target('render_with_interfaces') + self.target.do_openstack_upgrade_config_render('int_list') + self.render_with_interfaces.assert_called_once_with('int_list') + + def test_do_openstack_upgrade_db_migration(self): + self.patch_object(chm_core.hookenv, 'is_leader') + self.patch_object(chm_core.subprocess, 'check_call') + self.patch_object(chm_core.hookenv, 'log') + # Check migration not run if not leader + self.is_leader.return_value = False + self.target.do_openstack_upgrade_db_migration() + self.assertFalse(self.check_call.called) + # Check migration run on leader + self.is_leader.return_value = True + self.target.do_openstack_upgrade_db_migration() + self.check_call.assert_called_once_with(['my-sync-cmd', 'param1']) diff --git a/unit_tests/charms_openstack/charm/test_defaults.py b/unit_tests/charms_openstack/charm/test_defaults.py new file mode 100644 index 0000000..f4421d6 --- /dev/null +++ b/unit_tests/charms_openstack/charm/test_defaults.py @@ -0,0 +1,253 @@ +import collections +import mock + +import charms_openstack.charm.core as chm_core +import charms_openstack.charm.defaults as chm + +from unit_tests.charms_openstack.charm.utils import BaseOpenStackCharmTest + +TEST_CONFIG = {'config': True} + + +class TestDefaults(BaseOpenStackCharmTest): + + def setUp(self): + super().setUp(chm_core.BaseOpenStackCharm, TEST_CONFIG) + + def test_use_defaults(self): + self.patch_object(chm, 'ALLOWED_DEFAULT_HANDLERS', new=['handler']) + self.patch_object(chm, '_default_handler_map', new={}) + # first check for a missing handler. + with self.assertRaises(RuntimeError): + chm.use_defaults('does not exist') + # now check for an allowed handler, but no function. + with self.assertRaises(RuntimeError): + chm.use_defaults('handler') + + class TestException(Exception): + pass + + # finally, have an actual handler. + @chm._map_default_handler('handler') + def do_handler(): + raise TestException() + + with self.assertRaises(TestException): + chm.use_defaults('handler') + + def test_map_default_handler(self): + self.patch_object(chm, 'ALLOWED_DEFAULT_HANDLERS', new=['handler']) + self.patch_object(chm, '_default_handler_map', new={}) + # test that we can only map allowed handlers. + with self.assertRaises(RuntimeError): + @chm._map_default_handler('does-not-exist') + def test_func1(): + pass + + # test we can only map a handler once + @chm._map_default_handler('handler') + def test_func2(): + pass + + with self.assertRaises(RuntimeError): + @chm._map_default_handler('handler') + def test_func3(): + pass + + @staticmethod + def mock_decorator_gen(): + _map = {} + + def mock_generator(state): + def wrapper(f): + _map[state] = f + + def wrapped(*args, **kwargs): + return f(*args, **kwargs) + return wrapped + return wrapper + + Handler = collections.namedtuple('Handler', ['map', 'decorator']) + return Handler(_map, mock_generator) + + @staticmethod + def mock_decorator_gen_simple(): + _func = {} + + def wrapper(f): + _func['function'] = f + + def wrapped(*args, **kwargs): + return f(*args, **kwargs) + return wrapped + + Handler = collections.namedtuple('Handler', ['map', 'decorator']) + return Handler(_func, wrapper) + + def test_default_install_handler(self): + self.assertIn('charm.installed', chm._default_handler_map) + self.patch_object(chm.reactive, 'when_not') + h = self.mock_decorator_gen() + self.when_not.side_effect = h.decorator + # call the default handler installer function, and check its map. + f = chm._default_handler_map['charm.installed'] + f() + self.assertIn('charm.installed', h.map) + # verify that the installed function calls the charm installer + self.patch_object(chm, 'OpenStackCharm', name='charm') + kv = mock.MagicMock() + self.patch_object(chm.unitdata, 'kv', new=lambda: kv) + self.patch_object(chm.reactive, 'set_state') + h.map['charm.installed']() + kv.unset.assert_called_once_with(chm.OPENSTACK_RELEASE_KEY) + self.charm.singleton.install.assert_called_once_with() + self.set_state.assert_called_once_with('charm.installed') + + def test_default_select_release_handler(self): + self.assertIn('charm.default-select-release', chm._default_handler_map) + self.patch_object(chm, 'register_os_release_selector') + h = self.mock_decorator_gen_simple() + self.register_os_release_selector.side_effect = h.decorator + # call the default handler installer function, and check its map. + f = chm._default_handler_map['charm.default-select-release'] + f() + self.assertIsNotNone(h.map['function']) + # verify that the installed function works + kv = mock.MagicMock() + self.patch_object(chm.unitdata, 'kv', new=lambda: kv) + self.patch_object(chm.os_utils, 'os_release') + # set a release + kv.get.return_value = 'one' + release = h.map['function']() + self.assertEqual(release, 'one') + kv.set.assert_not_called() + kv.get.assert_called_once_with(chm.OPENSTACK_RELEASE_KEY, None) + # No release set, ensure it calls os_release + kv.reset_mock() + kv.get.return_value = None + self.os_release.return_value = 'two' + release = h.map['function']() + self.assertEqual(release, 'two') + kv.set.assert_called_once_with(chm.OPENSTACK_RELEASE_KEY, 'two') + self.os_release.assert_called_once_with('python-keystonemiddleware') + + def test_default_amqp_connection_handler(self): + self.assertIn('amqp.connected', chm._default_handler_map) + self.patch_object(chm.reactive, 'when') + h = self.mock_decorator_gen() + self.when.side_effect = h.decorator + # call the default handler installer function, and check its map. + f = chm._default_handler_map['amqp.connected'] + f() + self.assertIn('amqp.connected', h.map) + # verify that the installed function works + self.patch_object(chm, 'OpenStackCharm', name='charm') + self.charm.singleton.get_amqp_credentials.return_value = \ + ('user', 'vhost') + amqp = mock.MagicMock() + h.map['amqp.connected'](amqp) + self.charm.singleton.get_amqp_credentials.assert_called_once_with() + amqp.request_access.assert_called_once_with(username='user', + vhost='vhost') + self.charm.singleton.assess_status.assert_called_once_with() + + def test_default_setup_datatbase_handler(self): + self.assertIn('shared-db.connected', chm._default_handler_map) + self.patch_object(chm.reactive, 'when') + h = self.mock_decorator_gen() + self.when.side_effect = h.decorator + # call the default handler installer function, and check its map. + f = chm._default_handler_map['shared-db.connected'] + f() + self.assertIn('shared-db.connected', h.map) + # verify that the installed function works + self.patch_object(chm, 'OpenStackCharm', name='charm') + self.charm.singleton.get_database_setup.return_value = [ + {'database': 'configuration'}] + database = mock.MagicMock() + h.map['shared-db.connected'](database) + self.charm.singleton.get_database_setup.assert_called_once_with() + database.configure.assert_called_once_with(database='configuration') + self.charm.singleton.assess_status.assert_called_once_with() + + def test_default_setup_endpoint_handler(self): + self.assertIn('identity-service.connected', chm._default_handler_map) + self.patch_object(chm.reactive, 'when') + h = self.mock_decorator_gen() + self.when.side_effect = h.decorator + # call the default handler installer function, and check its map. + f = chm._default_handler_map['identity-service.connected'] + f() + self.assertIn('identity-service.connected', h.map) + # verify that the installed function works + + OpenStackCharm = mock.MagicMock() + + class Instance(object): + service_type = 'type1' + region = 'region1' + public_url = 'public_url' + internal_url = 'internal_url' + admin_url = 'admin_url' + assess_status = mock.MagicMock() + + OpenStackCharm.singleton = Instance + with mock.patch.object(chm, 'OpenStackCharm', new=OpenStackCharm): + keystone = mock.MagicMock() + h.map['identity-service.connected'](keystone) + keystone.register_endpoints.assert_called_once_with( + 'type1', 'region1', 'public_url', 'internal_url', 'admin_url') + Instance.assess_status.assert_called_once_with() + + def test_default_setup_endpoint_available_handler(self): + self.assertIn('identity-service.available', chm._default_handler_map) + self.patch_object(chm.reactive, 'when') + h = self.mock_decorator_gen() + self.when.side_effect = h.decorator + # call the default handler installer function, and check its map. + f = chm._default_handler_map['identity-service.available'] + f() + self.assertIn('identity-service.available', h.map) + # verify that the installed function works + self.patch_object(chm, 'OpenStackCharm', name='charm') + h.map['identity-service.available']('keystone') + self.charm.singleton.configure_ssl.assert_called_once_with('keystone') + self.charm.singleton.assess_status.assert_called_once_with() + + def test_default_config_changed_handler(self): + self.assertIn('config.changed', chm._default_handler_map) + self.patch_object(chm.reactive, 'when') + h = self.mock_decorator_gen() + self.when.side_effect = h.decorator + # call the default handler installer function, and check its map. + f = chm._default_handler_map['config.changed'] + f() + self.assertIn('config.changed', h.map) + # verify that the installed function works + self.patch_object(chm, 'OpenStackCharm', name='charm') + h.map['config.changed']() + self.charm.singleton.assess_status.assert_called_once_with() + + def test_default_update_status_handler(self): + self.assertIn('update-status', chm._default_handler_map) + self.patch_object(chm.reactive, 'hook') + h = self.mock_decorator_gen() + self.hook.side_effect = h.decorator + # call the default handler installer function, and check its map. + f = chm._default_handler_map['update-status'] + f() + self.assertIn('update-status', h.map) + # verify that the installed function works + self.patch_object(chm, 'OpenStackCharm', name='charm') + self.patch_object(chm.hookenv, 'application_version_set') + h.map['update-status']() + self.charm.singleton.assess_status.assert_called_once_with() + self.application_version_set.assert_called_once_with(mock.ANY) + + def test_default_render_configs(self): + self.patch_object(chm, 'OpenStackCharm', name='charm') + interfaces = ['a', 'b', 'c'] + chm.default_render_configs(*interfaces) + self.charm.singleton.render_configs.assert_called_once_with( + tuple(interfaces)) + self.charm.singleton.assess_status.assert_called_once_with() diff --git a/unit_tests/charms_openstack/charm/utils.py b/unit_tests/charms_openstack/charm/utils.py new file mode 100644 index 0000000..9446c82 --- /dev/null +++ b/unit_tests/charms_openstack/charm/utils.py @@ -0,0 +1,36 @@ +import mock + +import unit_tests.utils + +import charms_openstack.charm.core as chm_core + + +class BaseOpenStackCharmTest(unit_tests.utils.BaseTestCase): + + @classmethod + def setUpClass(cls): + cls.patched_config = mock.patch.object(chm_core.hookenv, 'config') + cls.patched_config_started = cls.patched_config.start() + + @classmethod + def tearDownClass(cls): + cls.patched_config.stop() + cls.patched_config_started = None + cls.patched_config = None + + def setUp(self, target_cls, test_config): + super().setUp() + # set up the return value on the mock before instantiating the class to + # get the config into the class.config. + chm_core.hookenv.config.return_value = test_config + self.target = target_cls() + + def tearDown(self): + self.target = None + # if we've created a singleton on the module, also destroy that. + chm_core._singleton = None + super().tearDown() + + def patch_target(self, attr, return_value=None, name=None, new=None): + # uses BaseTestCase.patch_object() to patch targer. + self.patch_object(self.target, attr, return_value, name, new) diff --git a/unit_tests/test_charms_openstack_charm.py b/unit_tests/test_charms_openstack_charm.py deleted file mode 100644 index d7eef82..0000000 --- a/unit_tests/test_charms_openstack_charm.py +++ /dev/null @@ -1,1687 +0,0 @@ -# Copyright 2016 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. - -# Note that the unit_tests/__init__.py has the following lines to stop -# side effects from the imorts from charm helpers. - -# mock out some charmhelpers libraries as they have apt install side effects -# sys.modules['charmhelpers.contrib.openstack.utils'] = mock.MagicMock() -# sys.modules['charmhelpers.contrib.network.ip'] = mock.MagicMock() -from __future__ import absolute_import - -import base64 -import collections -import unittest - -import mock - -import unit_tests.utils as utils - -import charms_openstack.charm as chm - -TEST_CONFIG = {'config': True} - - -class BaseOpenStackCharmTest(utils.BaseTestCase): - - @classmethod - def setUpClass(cls): - cls.patched_config = mock.patch.object(chm.hookenv, 'config') - cls.patched_config_started = cls.patched_config.start() - - @classmethod - def tearDownClass(cls): - cls.patched_config.stop() - cls.patched_config_started = None - cls.patched_config = None - - def setUp(self, target_cls, test_config): - super(BaseOpenStackCharmTest, self).setUp() - # set up the return value on the mock before instantiating the class to - # get the config into the class.config. - chm.hookenv.config.return_value = test_config - self.target = target_cls() - - def tearDown(self): - self.target = None - # if we've created a singleton on the module, also destroy that. - chm._singleton = None - super(BaseOpenStackCharmTest, self).tearDown() - - def patch_target(self, attr, return_value=None, name=None, new=None): - # uses BaseTestCase.patch_object() to patch targer. - self.patch_object(self.target, attr, return_value, name, new) - - -class TestOpenStackCharmMeta(BaseOpenStackCharmTest): - - def setUp(self): - super(TestOpenStackCharmMeta, self).setUp( - chm.OpenStackCharm, TEST_CONFIG) - - def test_register_classes(self): - self.patch_object(chm, '_releases', new={}) - - class TestC1(chm.OpenStackCharm): - release = 'liberty' - - class TestC2(chm.OpenStackCharm): - release = 'mitaka' - - self.assertTrue('liberty' in chm._releases.keys()) - self.assertTrue('mitaka' in chm._releases.keys()) - self.assertEqual(chm._releases['liberty'], TestC1) - self.assertEqual(chm._releases['mitaka'], TestC2) - - def test_register_unknown_series(self): - self.patch_object(chm, '_releases', new={}) - with self.assertRaises(RuntimeError): - class TestC1(chm.OpenStackCharm): - release = 'unknown' - - def test_register_repeated_series(self): - self.patch_object(chm, '_releases', new={}) - with self.assertRaises(RuntimeError): - class TestC1(chm.OpenStackCharm): - release = 'liberty' - - class TestC2(chm.OpenStackCharm): - release = 'liberty' - - -class TestFunctions(BaseOpenStackCharmTest): - - def setUp(self): - super(TestFunctions, self).setUp( - chm.OpenStackCharm, TEST_CONFIG) - self.patch_object(chm, '_releases', new={}) - - class TestC1(chm.OpenStackCharm): - release = 'icehouse' - - class TestC2(chm.OpenStackCharm): - release = 'kilo' - - class TestC3(chm.OpenStackCharm): - release = 'mitaka' - - self.C1, self.C2, self.C3 = TestC1, TestC2, TestC3 - - def test_get_exact(self): - self.assertTrue( - isinstance(chm.get_charm_instance(release='icehouse'), self.C1)) - self.assertTrue( - isinstance(chm.get_charm_instance(release='mitaka'), self.C3)) - - def test_get_inbetween(self): - self.assertTrue( - isinstance(chm.get_charm_instance(release='juno'), self.C1)) - - def test_fail_too_early_series(self): - with self.assertRaises(RuntimeError): - chm.get_charm_instance(release='havana') - - def test_get_default_release(self): - # TODO this may be the wrong logic. Assume latest release if no - # release is passed? - self.assertIsInstance(chm.get_charm_instance(), self.C3) - - -class TestRegisterOSReleaseSelector(unittest.TestCase): - - def test_register(self): - save_rsf = chm._release_selector_function - chm._release_selector_function = None - - @chm.register_os_release_selector - def test_func(): - pass - - self.assertEqual(chm._release_selector_function, test_func) - chm._release_selector_function = save_rsf - - def test_cant_register_more_than_once(self): - save_rsf = chm._release_selector_function - chm._release_selector_function = None - - @chm.register_os_release_selector - def test_func1(): - pass - - with self.assertRaises(RuntimeError): - @chm.register_os_release_selector - def test_func2(): - pass - - self.assertEqual(chm._release_selector_function, test_func1) - chm._release_selector_function = save_rsf - - -class TestDefaults(BaseOpenStackCharmTest): - - def setUp(self): - super(TestDefaults, self).setUp(chm.OpenStackCharm, TEST_CONFIG) - - def test_use_defaults(self): - self.patch_object(chm, 'ALLOWED_DEFAULT_HANDLERS', new=['handler']) - self.patch_object(chm, '_default_handler_map', new={}) - # first check for a missing handler. - with self.assertRaises(RuntimeError): - chm.use_defaults('does not exist') - # now check for an allowed handler, but no function. - with self.assertRaises(RuntimeError): - chm.use_defaults('handler') - - class TestException(Exception): - pass - - # finally, have an actual handler. - @chm._map_default_handler('handler') - def do_handler(): - raise TestException() - - with self.assertRaises(TestException): - chm.use_defaults('handler') - - def test_map_default_handler(self): - self.patch_object(chm, 'ALLOWED_DEFAULT_HANDLERS', new=['handler']) - self.patch_object(chm, '_default_handler_map', new={}) - # test that we can only map allowed handlers. - with self.assertRaises(RuntimeError): - @chm._map_default_handler('does-not-exist') - def test_func1(): - pass - - # test we can only map a handler once - @chm._map_default_handler('handler') - def test_func2(): - pass - - with self.assertRaises(RuntimeError): - @chm._map_default_handler('handler') - def test_func3(): - pass - - @staticmethod - def mock_decorator_gen(): - _map = {} - - def mock_generator(state): - def wrapper(f): - _map[state] = f - - def wrapped(*args, **kwargs): - return f(*args, **kwargs) - return wrapped - return wrapper - - Handler = collections.namedtuple('Handler', ['map', 'decorator']) - return Handler(_map, mock_generator) - - @staticmethod - def mock_decorator_gen_simple(): - _func = {} - - def wrapper(f): - _func['function'] = f - - def wrapped(*args, **kwargs): - return f(*args, **kwargs) - return wrapped - - Handler = collections.namedtuple('Handler', ['map', 'decorator']) - return Handler(_func, wrapper) - - def test_default_install_handler(self): - self.assertIn('charm.installed', chm._default_handler_map) - self.patch_object(chm.reactive, 'when_not') - h = self.mock_decorator_gen() - self.when_not.side_effect = h.decorator - # call the default handler installer function, and check its map. - f = chm._default_handler_map['charm.installed'] - f() - self.assertIn('charm.installed', h.map) - # verify that the installed function calls the charm installer - self.patch_object(chm, 'OpenStackCharm', name='charm') - kv = mock.MagicMock() - self.patch_object(chm.unitdata, 'kv', new=lambda: kv) - self.patch_object(chm.reactive, 'set_state') - h.map['charm.installed']() - kv.unset.assert_called_once_with(chm.OPENSTACK_RELEASE_KEY) - self.charm.singleton.install.assert_called_once_with() - self.set_state.assert_called_once_with('charm.installed') - - def test_default_select_release_handler(self): - self.assertIn('charm.default-select-release', chm._default_handler_map) - self.patch_object(chm, 'register_os_release_selector') - h = self.mock_decorator_gen_simple() - self.register_os_release_selector.side_effect = h.decorator - # call the default handler installer function, and check its map. - f = chm._default_handler_map['charm.default-select-release'] - f() - self.assertIsNotNone(h.map['function']) - # verify that the installed function works - kv = mock.MagicMock() - self.patch_object(chm.unitdata, 'kv', new=lambda: kv) - self.patch_object(chm.os_utils, 'os_release') - # set a release - kv.get.return_value = 'one' - release = h.map['function']() - self.assertEqual(release, 'one') - kv.set.assert_not_called() - kv.get.assert_called_once_with(chm.OPENSTACK_RELEASE_KEY, None) - # No release set, ensure it calls os_release - kv.reset_mock() - kv.get.return_value = None - self.os_release.return_value = 'two' - release = h.map['function']() - self.assertEqual(release, 'two') - kv.set.assert_called_once_with(chm.OPENSTACK_RELEASE_KEY, 'two') - self.os_release.assert_called_once_with('python-keystonemiddleware') - - def test_default_amqp_connection_handler(self): - self.assertIn('amqp.connected', chm._default_handler_map) - self.patch_object(chm.reactive, 'when') - h = self.mock_decorator_gen() - self.when.side_effect = h.decorator - # call the default handler installer function, and check its map. - f = chm._default_handler_map['amqp.connected'] - f() - self.assertIn('amqp.connected', h.map) - # verify that the installed function works - self.patch_object(chm, 'OpenStackCharm', name='charm') - self.charm.singleton.get_amqp_credentials.return_value = \ - ('user', 'vhost') - amqp = mock.MagicMock() - h.map['amqp.connected'](amqp) - self.charm.singleton.get_amqp_credentials.assert_called_once_with() - amqp.request_access.assert_called_once_with(username='user', - vhost='vhost') - self.charm.singleton.assess_status.assert_called_once_with() - - def test_default_setup_datatbase_handler(self): - self.assertIn('shared-db.connected', chm._default_handler_map) - self.patch_object(chm.reactive, 'when') - h = self.mock_decorator_gen() - self.when.side_effect = h.decorator - # call the default handler installer function, and check its map. - f = chm._default_handler_map['shared-db.connected'] - f() - self.assertIn('shared-db.connected', h.map) - # verify that the installed function works - self.patch_object(chm, 'OpenStackCharm', name='charm') - self.charm.singleton.get_database_setup.return_value = [ - {'database': 'configuration'}] - database = mock.MagicMock() - h.map['shared-db.connected'](database) - self.charm.singleton.get_database_setup.assert_called_once_with() - database.configure.assert_called_once_with(database='configuration') - self.charm.singleton.assess_status.assert_called_once_with() - - def test_default_setup_endpoint_handler(self): - self.assertIn('identity-service.connected', chm._default_handler_map) - self.patch_object(chm.reactive, 'when') - h = self.mock_decorator_gen() - self.when.side_effect = h.decorator - # call the default handler installer function, and check its map. - f = chm._default_handler_map['identity-service.connected'] - f() - self.assertIn('identity-service.connected', h.map) - # verify that the installed function works - - OpenStackCharm = mock.MagicMock() - - class Instance(object): - service_type = 'type1' - region = 'region1' - public_url = 'public_url' - internal_url = 'internal_url' - admin_url = 'admin_url' - assess_status = mock.MagicMock() - - OpenStackCharm.singleton = Instance - with mock.patch.object(chm, 'OpenStackCharm', new=OpenStackCharm): - keystone = mock.MagicMock() - h.map['identity-service.connected'](keystone) - keystone.register_endpoints.assert_called_once_with( - 'type1', 'region1', 'public_url', 'internal_url', 'admin_url') - Instance.assess_status.assert_called_once_with() - - def test_default_setup_endpoint_available_handler(self): - self.assertIn('identity-service.available', chm._default_handler_map) - self.patch_object(chm.reactive, 'when') - h = self.mock_decorator_gen() - self.when.side_effect = h.decorator - # call the default handler installer function, and check its map. - f = chm._default_handler_map['identity-service.available'] - f() - self.assertIn('identity-service.available', h.map) - # verify that the installed function works - self.patch_object(chm, 'OpenStackCharm', name='charm') - h.map['identity-service.available']('keystone') - self.charm.singleton.configure_ssl.assert_called_once_with('keystone') - self.charm.singleton.assess_status.assert_called_once_with() - - def test_default_config_changed_handler(self): - self.assertIn('config.changed', chm._default_handler_map) - self.patch_object(chm.reactive, 'when') - h = self.mock_decorator_gen() - self.when.side_effect = h.decorator - # call the default handler installer function, and check its map. - f = chm._default_handler_map['config.changed'] - f() - self.assertIn('config.changed', h.map) - # verify that the installed function works - self.patch_object(chm, 'OpenStackCharm', name='charm') - h.map['config.changed']() - self.charm.singleton.assess_status.assert_called_once_with() - - def test_default_update_status_handler(self): - self.assertIn('update-status', chm._default_handler_map) - self.patch_object(chm.reactive, 'hook') - h = self.mock_decorator_gen() - self.hook.side_effect = h.decorator - # call the default handler installer function, and check its map. - f = chm._default_handler_map['update-status'] - f() - self.assertIn('update-status', h.map) - # verify that the installed function works - self.patch_object(chm, 'OpenStackCharm', name='charm') - h.map['update-status']() - self.charm.singleton.assess_status.assert_called_once_with() - - def test_default_render_configs(self): - self.patch_object(chm, 'OpenStackCharm', name='charm') - interfaces = ['a', 'b', 'c'] - chm.default_render_configs(*interfaces) - self.charm.singleton.render_configs.assert_called_once_with( - tuple(interfaces)) - self.charm.singleton.assess_status.assert_called_once_with() - - def test_optional_interfaces(self): - self.patch_object(chm.reactive, 'RelationBase', name='relation_base') - self.relation_base.from_state.side_effect = ['x', None, 'z'] - r = chm.optional_interfaces(('a', 'b', 'c'), 'any', 'old', 'thing') - self.assertEqual(r, ('a', 'b', 'c', 'x', 'z')) - self.relation_base.from_state.assert_has_calls( - [mock.call('any'), mock.call('old'), mock.call('thing')]) - - -class TestProvideCharmInstance(utils.BaseTestCase): - - def test_provide_charm_instance_as_decorator(self): - self.patch_object(chm, 'OpenStackCharm', name='charm') - self.charm.singleton = 'the-charm' - - @chm.provide_charm_instance - def the_handler(charm_instance, *args): - self.assertEqual(charm_instance, 'the-charm') - self.assertEqual(args, (1, 2, 3)) - - the_handler(1, 2, 3) - - def test_provide_charm_instance_as_context_manager(self): - self.patch_object(chm, 'OpenStackCharm', name='charm') - self.charm.singleton = 'the-charm' - - with chm.provide_charm_instance() as charm: - self.assertEqual(charm, 'the-charm') - - -class TestOpenStackCharm__init__(BaseOpenStackCharmTest): - # Just test the __init__() function, as it takes some params which do some - # initalisation. - - def setUp(self): - - class NoOp(object): - pass - - # bypass setting p the charm directly, as we want control over that. - super(TestOpenStackCharm__init__, self).setUp(NoOp, TEST_CONFIG) - - def test_empty_init_args(self): - target = chm.OpenStackCharm() - self.assertIsNone(target.release) - # we expect target.adapters_instance to not be None as - # target.adapters_class is not None as a default - self.assertIsNotNone(target.adapters_instance) - # from mocked hookenv.config() - self.assertEqual(target.config, TEST_CONFIG) - - def test_filled_init_args(self): - self.patch_object(chm, '_releases', new={}) - - class TestCharm(chm.OpenStackCharm): - release = 'mitaka' - adapters_class = mock.MagicMock() - - target = TestCharm('interfaces', 'config', 'release') - self.assertEqual(target.release, 'release') - self.assertEqual(target.config, 'config') - self.assertIsInstance(target.adapters_instance, mock.MagicMock) - TestCharm.adapters_class.assert_called_once_with( - 'interfaces', charm_instance=target) - - -class TestOpenStackCharm(BaseOpenStackCharmTest): - # Note that this only tests the OpenStackCharm() class, which has not very - # useful defaults for testing. In order to test all the code without too - # many mocks, a separate test dervied charm class is used below. - - def setUp(self): - super(TestOpenStackCharm, self).setUp(chm.OpenStackCharm, TEST_CONFIG) - - def test__init__(self): - # Note cls.setUpClass() creates an OpenStackCharm() instance - self.assertEqual(chm.hookenv.config(), TEST_CONFIG) - self.assertEqual(self.target.config, TEST_CONFIG) - # Note that we assume NO release unless given one. - self.assertEqual(self.target.release, None) - - def test_install(self): - # only tests that the default set_state is called - self.patch_target('set_state') - self.patch_object(chm.charmhelpers.fetch, - 'filter_installed_packages', - name='fip', - return_value=None) - self.patch_object(chm.subprocess, 'check_output', return_value=b'\n') - self.target.install() - self.target.set_state.assert_called_once_with('charmname-installed') - self.fip.assert_called_once_with([]) - - def test_all_packages(self): - self.assertEqual(self.target.packages, self.target.all_packages) - - def test_full_restart_map(self): - self.assertEqual(self.target.full_restart_map, self.target.restart_map) - - def test_set_state(self): - # tests that OpenStackCharm.set_state() calls set_state() global - self.patch_object(chm.reactive.bus, 'set_state') - self.target.set_state('hello') - self.set_state.assert_called_once_with('hello', None) - self.set_state.reset_mock() - self.target.set_state('hello', 'there') - self.set_state.assert_called_once_with('hello', 'there') - - def test_remove_state(self): - # tests that OpenStackCharm.remove_state() calls remove_state() global - self.patch_object(chm.reactive.bus, 'remove_state') - self.target.remove_state('hello') - self.remove_state.assert_called_once_with('hello') - - def test_configure_source(self): - self.patch_object(chm.os_utils, - 'configure_installation_source', - name='cis') - self.patch_object(chm.charmhelpers.fetch, 'apt_update') - self.patch_target('config', new={'openstack-origin': 'an-origin'}) - self.target.configure_source() - self.cis.assert_called_once_with('an-origin') - self.apt_update.assert_called_once_with(fatal=True) - - def test_region(self): - self.patch_target('config', new={'region': 'a-region'}) - self.assertEqual(self.target.region, 'a-region') - - def test_restart_on_change(self): - from collections import OrderedDict - hashs = OrderedDict([ - ('path1', 100), - ('path2', 200), - ('path3', 300), - ('path4', 400), - ]) - self.target.restart_map = { - 'path1': ['s1'], - 'path2': ['s2'], - 'path3': ['s3'], - 'path4': ['s2', 's4'], - } - self.patch_object(chm.ch_host, 'path_hash') - self.path_hash.side_effect = lambda x: hashs[x] - self.patch_object(chm.ch_host, 'service_stop') - self.patch_object(chm.ch_host, 'service_start') - # slightly awkard, in that we need to test a context manager - with self.target.restart_on_change(): - # test with no restarts - pass - self.assertEqual(self.service_stop.call_count, 0) - self.assertEqual(self.service_start.call_count, 0) - - with self.target.restart_on_change(): - # test with path1 and path3 restarts - for k in ['path1', 'path3']: - hashs[k] += 1 - self.assertEqual(self.service_stop.call_count, 2) - self.assertEqual(self.service_start.call_count, 2) - self.service_stop.assert_any_call('s1') - self.service_stop.assert_any_call('s3') - self.service_start.assert_any_call('s1') - self.service_start.assert_any_call('s3') - - # test with path2 and path4 and that s2 only gets restarted once - self.service_stop.reset_mock() - self.service_start.reset_mock() - with self.target.restart_on_change(): - for k in ['path2', 'path4']: - hashs[k] += 1 - self.assertEqual(self.service_stop.call_count, 2) - self.assertEqual(self.service_start.call_count, 2) - calls = [mock.call('s2'), mock.call('s4')] - self.service_stop.assert_has_calls(calls) - self.service_start.assert_has_calls(calls) - - def test_restart_all(self): - self.patch_object(chm.ch_host, 'service_restart') - self.patch_target('services', new=['s1', 's2']) - self.target.restart_all() - self.assertEqual(self.service_restart.call_args_list, - [mock.call('s1'), mock.call('s2')]) - - def test_db_sync_done(self): - self.patch_object(chm.hookenv, 'leader_get') - self.leader_get.return_value = True - self.assertTrue(self.target.db_sync_done()) - self.leader_get.return_value = False - self.assertFalse(self.target.db_sync_done()) - - def test_db_sync(self): - self.patch_object(chm.hookenv, 'is_leader') - self.patch_object(chm.hookenv, 'leader_get') - self.patch_object(chm.hookenv, 'leader_set') - self.patch_object(chm, 'subprocess', name='subprocess') - self.patch_target('restart_all') - # first check with leader_get returning True - self.leader_get.return_value = True - self.is_leader.return_value = True - self.target.db_sync() - self.leader_get.assert_called_once_with(attribute='db-sync-done') - self.subprocess.check_call.assert_not_called() - self.leader_set.assert_not_called() - # Now check with leader_get returning False - self.leader_get.reset_mock() - self.leader_get.return_value = False - self.target.sync_cmd = ['a', 'cmd'] - self.target.db_sync() - self.leader_get.assert_called_once_with(attribute='db-sync-done') - self.subprocess.check_call.assert_called_once_with(['a', 'cmd']) - self.leader_set.assert_called_once_with({'db-sync-done': True}) - # Now check with is_leader returning False - self.leader_set.reset_mock() - self.subprocess.check_call.reset_mock() - self.leader_get.return_value = True - self.is_leader.return_value = False - self.target.db_sync() - self.subprocess.check_call.assert_not_called() - self.leader_set.assert_not_called() - - -class TestOpenStackAPICharm(BaseOpenStackCharmTest): - - def setUp(self): - super(TestOpenStackAPICharm, self).setUp(chm.OpenStackAPICharm, - TEST_CONFIG) - - def test_upgrade_charm(self): - self.patch_target('setup_token_cache') - self.patch_target('update_api_ports') - self.target.upgrade_charm() - self.target.setup_token_cache.assert_called_once_with() - - def test_install(self): - # Test set_state and configure_source are called - self.patch_target('set_state') - self.patch_target('configure_source') - self.patch_target('enable_memcache', return_value=False) - self.patch_object(chm.charmhelpers.fetch, - 'filter_installed_packages', - name='fip', - return_value=None) - self.patch_object(chm.subprocess, 'check_output', return_value=b'\n') - self.target.install() - # self.target.set_state.assert_called_once_with('charmname-installed') - self.target.configure_source.assert_called_once_with() - self.fip.assert_called_once_with([]) - - def test_setup_token_cache(self): - self.patch_target('token_cache_pkgs') - self.patch_target('install') - self.patch_object(chm.charmhelpers.fetch, - 'filter_installed_packages', - name='fip', - return_value=['memcached']) - self.target.setup_token_cache() - self.install.assert_called_once_with() - self.fip.return_value = [] - self.install.reset_mock() - self.target.setup_token_cache() - self.assertFalse(self.install.called) - - def test_enable_memcache(self): - self.assertFalse(self.target.enable_memcache(release='liberty')) - self.assertTrue(self.target.enable_memcache(release='newton')) - self.patch_target('config', new={'openstack-origin': 'distro'}) - self.patch_object(chm.os_utils, - 'get_os_codename_install_source', - name='gocis') - self.gocis.return_value = 'liberty' - self.assertFalse(self.target.enable_memcache()) - self.gocis.return_value = 'newton' - self.assertTrue(self.target.enable_memcache()) - - def test_token_cache_pkgs(self): - self.patch_target('enable_memcache') - self.enable_memcache.return_value = True - self.assertEqual(self.target.token_cache_pkgs(), ['memcached', - 'python-memcache']) - self.enable_memcache.return_value = False - self.assertEqual(self.target.token_cache_pkgs(), []) - - def test_get_amqp_credentials(self): - # verify that the instance throws an error if not overriden - with self.assertRaises(RuntimeError): - self.target.get_amqp_credentials() - - def test_get_database_setup(self): - # verify that the instance throws an error if not overriden - with self.assertRaises(RuntimeError): - self.target.get_database_setup() - - def test_all_packages(self): - self.patch_target('enable_memcache') - self.patch_target('packages', new=['pkg1', 'pkg2']) - self.enable_memcache.return_value = True - self.assertEqual(self.target.all_packages, - ['pkg1', 'pkg2', 'memcached', 'python-memcache']) - self.enable_memcache.return_value = False - self.assertEqual(self.target.all_packages, ['pkg1', 'pkg2']) - - def test_full_restart_map(self): - self.patch_target('enable_memcache') - base_restart_map = { - 'conf1': ['svc1'], - 'conf2': ['svc1']} - self.patch_target('restart_map', new=base_restart_map) - self.enable_memcache.return_value = True - self.assertEqual(self.target.full_restart_map, - {'conf1': ['svc1'], - 'conf2': ['svc1'], - '/etc/memcached.conf': ['memcached']}) - self.enable_memcache.return_value = False - self.assertEqual(self.target.full_restart_map, base_restart_map) - - -class TestHAOpenStackCharm(BaseOpenStackCharmTest): - # Note that this only tests the OpenStackCharm() class, which has not very - # useful defaults for testing. In order to test all the code without too - # many mocks, a separate test dervied charm class is used below. - - def setUp(self): - super(TestHAOpenStackCharm, self).setUp(chm.HAOpenStackCharm, - TEST_CONFIG) - - def test_all_packages(self): - self.patch_target('packages', new=['pkg1']) - self.patch_target('token_cache_pkgs', return_value=[]) - self.patch_target('haproxy_enabled', return_value=False) - self.patch_target('apache_enabled', return_value=False) - self.assertEqual(['pkg1'], self.target.all_packages) - self.token_cache_pkgs.return_value = ['memcache'] - self.haproxy_enabled.return_value = True - self.apache_enabled.return_value = True - self.assertEqual(['pkg1', 'memcache', 'haproxy', 'apache2'], - self.target.all_packages) - - def test_full_restart_map_disabled(self): - base_restart_map = { - 'conf1': ['svc1'], - 'conf2': ['svc1']} - self.patch_target('restart_map', new=base_restart_map) - self.patch_target('enable_memcache', return_value=False) - self.patch_target('haproxy_enabled', return_value=False) - self.patch_target('apache_enabled', return_value=False) - self.assertEqual(base_restart_map, self.target.full_restart_map) - - def test_full_restart_map_enabled(self): - base_restart_map = { - 'conf1': ['svc1'], - 'conf2': ['svc1']} - self.patch_target('restart_map', new=base_restart_map) - self.patch_target('enable_memcache', return_value=True) - self.patch_target('haproxy_enabled', return_value=True) - self.patch_target('apache_enabled', return_value=True) - self.assertEqual( - self.target.full_restart_map, - {'/etc/apache2/sites-available/openstack_https_frontend.conf': - ['apache2'], - '/etc/haproxy/haproxy.cfg': ['haproxy'], - '/etc/memcached.conf': ['memcached'], - 'conf1': ['svc1'], - 'conf2': ['svc1']}) - - def test_haproxy_enabled(self): - self.patch_target('ha_resources', new=['haproxy']) - self.assertTrue(self.target.haproxy_enabled()) - - def test__init__(self): - # Note cls.setUpClass() creates an OpenStackCharm() instance - self.assertEqual(chm.hookenv.config(), TEST_CONFIG) - self.assertEqual(self.target.config, TEST_CONFIG) - # Note that we assume NO release unless given one. - self.assertEqual(self.target.release, None) - - def test_configure_ha_resources(self): - interface_mock = mock.Mock() - self.patch_target('config', new={'vip_iface': 'ens12'}) - self.patch_target('ha_resources', new=['haproxy', 'vips']) - self.patch_target('_add_ha_vips_config') - self.patch_target('_add_ha_haproxy_config') - self.target.configure_ha_resources(interface_mock) - self._add_ha_vips_config.assert_called_once_with(interface_mock) - self._add_ha_haproxy_config.assert_called_once_with(interface_mock) - interface_mock.bind_resources.assert_called_once_with(iface='ens12') - - def test__add_ha_vips_config(self): - ifaces = { - 'vip1': 'eth1', - 'vip2': 'eth2'} - masks = { - 'vip1': 'netmask1', - 'vip2': 'netmask2'} - interface_mock = mock.Mock() - self.patch_target('name', new='myservice') - self.patch_target('config', new={'vip': 'vip1 vip2'}) - self.patch_object(chm.ch_ip, 'get_iface_for_address') - self.get_iface_for_address.side_effect = lambda x: ifaces[x] - self.patch_object(chm.ch_ip, 'get_netmask_for_address') - self.get_netmask_for_address.side_effect = lambda x: masks[x] - self.target._add_ha_vips_config(interface_mock) - calls = [ - mock.call('myservice', 'vip1', 'eth1', 'netmask1'), - mock.call('myservice', 'vip2', 'eth2', 'netmask2')] - interface_mock.add_vip.assert_has_calls(calls) - - def test__add_ha_vips_config_fallback(self): - config = { - 'vip_cidr': 'user_cidr', - 'vip_iface': 'user_iface', - 'vip': 'vip1 vip2'} - interface_mock = mock.Mock() - self.patch_target('name', new='myservice') - self.patch_target('config', new=config) - self.patch_object(chm.ch_ip, 'get_iface_for_address') - self.patch_object(chm.ch_ip, 'get_netmask_for_address') - self.get_iface_for_address.return_value = None - self.get_netmask_for_address.return_value = None - self.target._add_ha_vips_config(interface_mock) - calls = [ - mock.call('myservice', 'vip1', 'user_iface', 'user_cidr'), - mock.call('myservice', 'vip2', 'user_iface', 'user_cidr')] - interface_mock.add_vip.assert_has_calls(calls) - - def test__add_ha_haproxy_config(self): - self.patch_target('name', new='myservice') - interface_mock = mock.Mock() - self.target._add_ha_haproxy_config(interface_mock) - interface_mock.add_init_service.assert_called_once_with( - 'myservice', - 'haproxy') - - def test_set_haproxy_stat_password(self): - self.patch_object(chm.reactive.bus, 'get_state') - self.patch_object(chm.reactive.bus, 'set_state') - self.get_state.return_value = None - self.target.set_haproxy_stat_password() - self.set_state.assert_called_once_with('haproxy.stat.password', - mock.ANY) - - def test_hacharm_all_packages_enabled(self): - self.patch_target('enable_memcache', return_value=False) - self.patch_target('haproxy_enabled', return_value=True) - self.assertTrue('haproxy' in self.target.all_packages) - - def test_hacharm_all_packages_disabled(self): - self.patch_target('enable_memcache', return_value=False) - self.patch_target('haproxy_enabled', return_value=False) - self.assertFalse('haproxy' in self.target.all_packages) - - def test_hacharm_full_restart_map(self): - self.patch_target('enable_memcache', return_value=False) - self.patch_target('haproxy_enabled', return_value=True) - self.assertTrue( - self.target.full_restart_map.get( - '/etc/haproxy/haproxy.cfg', False)) - - def test_enable_apache_ssl_vhost(self): - self.patch_object(chm.os.path, 'exists', return_value=True) - self.patch_object(chm.subprocess, 'call', return_value=1) - self.patch_object(chm.subprocess, 'check_call') - self.target.enable_apache_ssl_vhost() - self.check_call.assert_called_once_with( - ['a2ensite', 'openstack_https_frontend']) - self.check_call.reset_mock() - self.patch_object(chm.subprocess, 'call', return_value=0) - self.target.enable_apache_ssl_vhost() - self.assertFalse(self.check_call.called) - - def test_enable_apache_modules(self): - apache_mods = { - 'ssl': 0, - 'proxy': 0, - 'proxy_http': 1} - self.patch_object(chm.ch_host, 'service_restart') - self.patch_object(chm.subprocess, 'check_call') - self.patch_object( - chm.subprocess, 'call', - new=lambda x: apache_mods[x.pop()]) - self.target.enable_apache_modules() - self.check_call.assert_called_once_with( - ['a2enmod', 'proxy_http']) - self.service_restart.assert_called_once_with('apache2') - - def test_configure_cert(self): - self.patch_object(chm.ch_host, 'mkdir') - self.patch_object(chm.ch_host, 'write_file') - self.target.configure_cert('mycert', 'mykey', cn='mycn') - self.mkdir.assert_called_once_with(path='/etc/apache2/ssl/charmname') - calls = [ - mock.call( - path='/etc/apache2/ssl/charmname/cert_mycn', - content=b'mycert'), - mock.call( - path='/etc/apache2/ssl/charmname/key_mycn', - content=b'mykey')] - self.write_file.assert_has_calls(calls) - self.write_file.reset_mock() - self.patch_object(chm.os_ip, 'resolve_address', 'addr') - self.target.configure_cert('mycert', 'mykey') - calls = [ - mock.call( - path='/etc/apache2/ssl/charmname/cert_addr', - content=b'mycert'), - mock.call( - path='/etc/apache2/ssl/charmname/key_addr', - content=b'mykey')] - self.write_file.assert_has_calls(calls) - - def test_get_local_addresses(self): - self.patch_object(chm.os_utils, 'get_host_ip', return_value='privaddr') - self.patch_object(chm.os_ip, 'resolve_address') - addresses = { - 'admin': 'admin_addr', - 'int': 'internal_addr', - 'public': 'public_addr'} - self.resolve_address.side_effect = \ - lambda endpoint_type=None: addresses[endpoint_type] - self.assertEqual( - self.target.get_local_addresses(), - ['admin_addr', 'internal_addr', 'privaddr', 'public_addr']) - - def test_get_certs_and_keys(self): - config = { - 'ssl_key': base64.b64encode(b'key'), - 'ssl_cert': base64.b64encode(b'cert'), - 'ssl_ca': base64.b64encode(b'ca')} - self.patch_target('config', new=config) - self.assertEqual( - self.target.get_certs_and_keys(), - [{'key': 'key', 'cert': 'cert', 'ca': 'ca', 'cn': None}]) - - def test_get_certs_and_keys_ks_interface(self): - class KSInterface(object): - def get_ssl_key(self, key): - keys = { - 'int_addr': 'int_key', - 'priv_addr': 'priv_key', - 'pub_addr': 'pub_key', - 'admin_addr': 'admin_key'} - return keys[key] - - def get_ssl_cert(self, key): - certs = { - 'int_addr': 'int_cert', - 'priv_addr': 'priv_cert', - 'pub_addr': 'pub_cert', - 'admin_addr': 'admin_cert'} - return certs[key] - - def get_ssl_ca(self): - return 'ca' - - self.patch_target( - 'get_local_addresses', - return_value=['int_addr', 'priv_addr', 'pub_addr', 'admin_addr']) - expect = [ - { - 'ca': 'ca', - 'cert': 'int_cert', - 'cn': 'int_addr', - 'key': 'int_key'}, - { - 'ca': 'ca', - 'cert': 'priv_cert', - 'cn': 'priv_addr', - 'key': 'priv_key'}, - { - 'ca': 'ca', - 'cert': 'pub_cert', - 'cn': 'pub_addr', - 'key': 'pub_key'}, - { - 'ca': 'ca', - 'cert': 'admin_cert', - 'cn': 'admin_addr', - 'key': 'admin_key'}] - - self.assertEqual( - self.target.get_certs_and_keys(keystone_interface=KSInterface()), - expect) - - def test_config_defined_certs_and_keys(self): - # test that the cached parameters do what we expect - config = { - 'ssl_key': base64.b64encode(b'confkey'), - 'ssl_cert': base64.b64encode(b'confcert'), - 'ssl_ca': base64.b64encode(b'confca')} - self.patch_target('config', new=config) - self.assertEqual(self.target.config_defined_ssl_key, b'confkey') - self.assertEqual(self.target.config_defined_ssl_cert, b'confcert') - self.assertEqual(self.target.config_defined_ssl_ca, b'confca') - - def test_configure_ssl(self): - ssl_objs = [ - { - 'cert': 'cert1', - 'key': 'key1', - 'ca': 'ca1', - 'cn': 'cn1'}, - { - 'cert': 'cert2', - 'key': 'key2', - 'ca': 'ca2', - 'cn': 'cn2'}] - self.patch_target('get_certs_and_keys', return_value=ssl_objs) - self.patch_target('configure_apache') - self.patch_target('configure_cert') - self.patch_target('configure_ca') - self.patch_object(chm.reactive.bus, 'set_state') - self.patch_object(chm.reactive.RelationBase, 'from_state', - return_value=None) - self.target.configure_ssl() - cert_calls = [ - mock.call('cert1', 'key1', cn='cn1'), - mock.call('cert2', 'key2', cn='cn2')] - ca_calls = [ - mock.call('ca1'), - mock.call('ca2')] - self.configure_cert.assert_has_calls(cert_calls) - self.configure_ca.assert_has_calls(ca_calls) - self.configure_apache.assert_called_once_with() - self.set_state.assert_called_once_with('ssl.enabled', True) - - def test_configure_ssl_off(self): - self.patch_target('get_certs_and_keys', return_value=[]) - self.patch_object(chm.reactive.bus, 'set_state') - self.patch_object(chm.reactive.RelationBase, 'from_state', - return_value=None) - self.target.configure_ssl() - self.set_state.assert_called_once_with('ssl.enabled', False) - - def test_configure_ssl_rabbit(self): - self.patch_target('get_certs_and_keys', return_value=[]) - self.patch_target('configure_rabbit_cert') - self.patch_object(chm.reactive.bus, 'set_state') - self.patch_object(chm.reactive.RelationBase, 'from_state', - return_value='ssl_int') - self.target.configure_ssl() - self.set_state.assert_called_once_with('ssl.enabled', False) - self.configure_rabbit_cert.assert_called_once_with('ssl_int') - - def test_configure_rabbit_cert(self): - rabbit_int_mock = mock.MagicMock() - rabbit_int_mock.get_ssl_cert.return_value = 'rabbit_cert' - self.patch_object(chm.os.path, 'exists', return_value=True) - self.patch_object(chm.os, 'mkdir') - self.patch_object(chm.hookenv, 'service_name', return_value='svc1') - with utils.patch_open() as (mock_open, mock_file): - self.target.configure_rabbit_cert(rabbit_int_mock) - mock_open.assert_called_with( - '/var/lib/charm/svc1/rabbit-client-ca.pem', - 'w') - mock_file.write.assert_called_with('rabbit_cert') - - def test_configure_ca(self): - self.patch_target('run_update_certs') - with utils.patch_open() as (mock_open, mock_file): - self.target.configure_ca('myca') - mock_open.assert_called_with( - '/usr/local/share/ca-certificates/keystone_juju_ca_cert.crt', - 'w') - mock_file.write.assert_called_with('myca') - - def test_run_update_certs(self): - self.patch_object(chm.subprocess, 'check_call') - self.target.run_update_certs() - self.check_call.assert_called_once_with( - ['update-ca-certificates', '--fresh']) - - def test_update_central_cacerts(self): - self.patch_target('run_update_certs') - change_hashes = ['hash1', 'hash2'] - nochange_hashes = ['hash1', 'hash1'] - - def fake_hash(hash_dict): - def fake_hash_inner(filename): - return hash_dict.pop() - return fake_hash_inner - self.patch_object(chm.ch_host, 'path_hash') - self.path_hash.side_effect = fake_hash(change_hashes) - with self.target.update_central_cacerts(['file1']): - pass - self.run_update_certs.assert_called_with() - self.run_update_certs.reset_mock() - self.path_hash.side_effect = fake_hash(nochange_hashes) - with self.target.update_central_cacerts(['file1']): - pass - self.assertFalse(self.run_update_certs.called) - - -class MyAdapter(object): - - def __init__(self, interfaces, charm_instance=None): - self.interfaces = interfaces - - -# force the series to just contain my-series. -# NOTE that this is mocked out in the __init__.py for the unit_tests package -chm.os_utils.OPENSTACK_CODENAMES = collections.OrderedDict([ - ('2011.2', 'my-series'), -]) - - -class MyOpenStackCharm(chm.OpenStackCharm): - - release = 'icehouse' - name = 'my-charm' - packages = ['p1', 'p2', 'p3', 'package-to-filter'] - version_package = 'p2' - api_ports = { - 'service1': { - chm.os_ip.PUBLIC: 1, - chm.os_ip.INTERNAL: 2, - }, - 'service2': { - chm.os_ip.PUBLIC: 3, - }, - 'my-default-service': { - chm.os_ip.PUBLIC: 1234, - chm.os_ip.ADMIN: 2468, - chm.os_ip.INTERNAL: 3579, - }, - } - service_type = 'my-service-type' - default_service = 'my-default-service' - restart_map = { - 'path1': ['s1'], - 'path2': ['s2'], - 'path3': ['s3'], - 'path4': ['s2', 's4'], - } - sync_cmd = ['my-sync-cmd', 'param1'] - services = ['my-default-service', 'my-second-service'] - adapters_class = MyAdapter - release_pkg = 'my-pkg' - - -class MyNextOpenStackCharm(MyOpenStackCharm): - - release = 'mitaka' - - -class TestMyOpenStackCharm(BaseOpenStackCharmTest): - - def setUp(self): - def make_open_stack_charm(): - return MyOpenStackCharm(['interface1', 'interface2']) - - super(TestMyOpenStackCharm, self).setUp(make_open_stack_charm, - TEST_CONFIG) - - def test_singleton(self): - # because we have two releases, we expect this to be the latter. - # e.g. MyNextOpenStackCharm - s = self.target.singleton - self.assertEqual(s.__class__.release, 'mitaka') - self.assertIsInstance(s, MyOpenStackCharm) - # should also be the second one, as it's the latest - self.assertIsInstance(s, MyNextOpenStackCharm) - self.assertIsInstance(MyOpenStackCharm.singleton, - MyOpenStackCharm) - self.assertIsInstance(chm.OpenStackCharm.singleton, - MyOpenStackCharm) - self.assertEqual(s, chm.OpenStackCharm.singleton) - # Note that get_charm_instance() returns NEW instance each time. - self.assertNotEqual(s, chm.get_charm_instance()) - # now clear out the singleton and make sure we get the first one using - # a release function - rsf_save = chm._release_selector_function - chm._release_selector_function = None - - @chm.register_os_release_selector - def selector(): - return 'icehouse' - - # This should choose the icehouse version instead of the mitaka version - chm._singleton = None - s = self.target.singleton - self.assertEqual(s.release, 'icehouse') - self.assertEqual(s.__class__.release, 'icehouse') - self.assertFalse(isinstance(s, MyNextOpenStackCharm)) - chm._release_selector_function = rsf_save - - def test_install(self): - # tests that the packages are filtered before installation - # self.patch_target('set_state') - self.patch_object(chm.charmhelpers.fetch, - 'filter_installed_packages', - return_value=None, - name='fip') - self.fip.side_effect = lambda x: ['p1', 'p2'] - self.patch_object(chm.hookenv, 'status_set') - self.patch_object(chm.hookenv, 'apt_install') - self.patch_object(chm.subprocess, 'check_output', return_value=b'\n') - self.target.install() - # TODO: remove next commented line as we don't set this state anymore - # self.target.set_state.assert_called_once_with('my-charm-installed') - self.fip.assert_called_once_with(self.target.packages) - self.status_set.assert_has_calls([ - mock.call('maintenance', 'Installing packages'), - mock.call('maintenance', - 'Installation complete - awaiting next status')]) - - def test_api_port(self): - self.assertEqual(self.target.api_port('service1'), 1) - self.assertEqual(self.target.api_port('service1', chm.os_ip.PUBLIC), 1) - self.assertEqual(self.target.api_port('service2'), 3) - with self.assertRaises(KeyError): - self.target.api_port('service3') - with self.assertRaises(KeyError): - self.target.api_port('service2', chm.os_ip.INTERNAL) - - def test_update_api_ports(self): - self.patch_object(chm.hookenv, 'open_port') - self.patch_object(chm.hookenv, 'close_port') - self.patch_object(chm.subprocess, 'check_output', return_value=b'\n') - self.target.api_ports = { - 'api': { - 'public': 1, - 'internal': 2, - 'admin': 3, - }, - } - test_ports = [4, 5, 6] - self.target.update_api_ports(test_ports) - calls = [mock.call(4), mock.call(5), mock.call(6)] - self.open_port.assert_has_calls(calls) - self.open_port.reset_mock() - self.target.update_api_ports() - calls = [mock.call(1), mock.call(2), mock.call(3)] - self.open_port.assert_has_calls(calls) - self.close_port.assert_not_called() - # now check that it doesn't open ports already open and closes ports - # that should be closed - self.open_port.reset_mock() - self.close_port.reset_mock() - self.check_output.return_value = b"1/tcp\n2/tcp\n3/udp\n4/tcp\n" - # port 3 should be opened, port 4 should be closed. - open_calls = [mock.call(3)] - close_calls = [mock.call(4)] - self.target.update_api_ports() - self.open_port.asset_has_calls(open_calls) - self.close_port.assert_has_calls(close_calls) - - def test_opened_ports(self): - self.patch_object(chm.subprocess, 'check_output') - self.check_output.return_value = b'\n' - self.assertEqual([], self.target.opened_ports()) - self.check_output.return_value = b'1/tcp\n2/tcp\n3/udp\n4/tcp\n5/udp\n' - self.assertEqual(['1', '2', '4'], self.target.opened_ports()) - self.assertEqual(['1', '2', '4'], - self.target.opened_ports(protocol='TCP')) - self.assertEqual(['3', '5'], self.target.opened_ports(protocol='udp')) - self.assertEqual(['1/tcp', '2/tcp', '3/udp', '4/tcp', '5/udp'], - self.target.opened_ports(protocol=None)) - self.assertEqual([], self.target.opened_ports(protocol='other')) - - def test_public_url(self): - self.patch_object(chm.os_ip, - 'canonical_url', - return_value='my-ip-address') - self.assertEqual(self.target.public_url, 'my-ip-address:1234') - self.canonical_url.assert_called_once_with(chm.os_ip.PUBLIC) - - def test_admin_url(self): - self.patch_object(chm.os_ip, - 'canonical_url', - return_value='my-ip-address') - self.assertEqual(self.target.admin_url, 'my-ip-address:2468') - self.canonical_url.assert_called_once_with(chm.os_ip.ADMIN) - - def test_internal_url(self): - self.patch_object(chm.os_ip, - 'canonical_url', - return_value='my-ip-address') - self.assertEqual(self.target.internal_url, 'my-ip-address:3579') - self.canonical_url.assert_called_once_with(chm.os_ip.INTERNAL) - - def test_application_version_unspecified(self): - self.patch_object(chm.os_utils, 'os_release') - self.patch_object(chm, 'get_upstream_version', - return_value='1.2.3') - self.target.version_package = None - self.assertEqual(self.target.application_version, '1.2.3') - self.get_upstream_version.assert_called_once_with('p1') - - def test_application_version_package(self): - self.patch_object(chm.os_utils, 'os_release') - self.patch_object(chm, 'get_upstream_version', - return_value='1.2.3') - self.assertEqual(self.target.application_version, '1.2.3') - self.get_upstream_version.assert_called_once_with('p2') - - def test_application_version_dfs(self): - self.patch_object(chm.os_utils, 'os_release', - return_value='mitaka') - self.patch_object(chm, 'get_upstream_version', - return_value=None) - self.assertEqual(self.target.application_version, 'mitaka') - self.get_upstream_version.assert_called_once_with('p2') - self.os_release.assert_called_once_with('p2') - - def test_render_all_configs(self): - self.patch_target('render_configs') - self.target.render_all_configs() - self.assertEqual(self.render_configs.call_count, 1) - args = self.render_configs.call_args_list[0][0][0] - self.assertEqual(['path1', 'path2', 'path3', 'path4'], - sorted(args)) - - def test_render_configs(self): - # give us a way to check that the context manager was called. - from contextlib import contextmanager - d = [0] - - @contextmanager - def fake_restart_on_change(): - d[0] += 1 - yield - - self.patch_target('restart_on_change', new=fake_restart_on_change) - self.patch_object(chm.charmhelpers.core.templating, 'render') - self.patch_object(chm.os_templating, - 'get_loader', - return_value='my-loader') - # self.patch_target('adapter_instance', new='my-adapter') - self.target.render_configs(['path1']) - self.assertEqual(d[0], 1) - self.render.assert_called_once_with( - source='path1', - template_loader='my-loader', - target='path1', - context=mock.ANY) - # assert the context was an MyAdapter instance. - context = self.render.call_args_list[0][1]['context'] - assert isinstance(context, MyAdapter) - self.assertEqual(context.interfaces, ['interface1', 'interface2']) - - def test_render_configs_singleton_render_with_interfaces(self): - self.patch_object(chm.charmhelpers.core.templating, 'render') - self.patch_object(chm.os_templating, - 'get_loader', - return_value='my-loader') - # also patch the cls.adapters_class to ensure that it is called with - # the target. - self.patch_object(self.target.singleton, 'adapters_class', - return_value='the-context') - - self.target.singleton.render_with_interfaces( - ['interface1', 'interface2']) - - self.adapters_class.assert_called_once_with( - ['interface1', 'interface2'], charm_instance=self.target.singleton) - - calls = [ - mock.call( - source='path1', - template_loader='my-loader', - target='path1', - context=mock.ANY), - mock.call( - source='path2', - template_loader='my-loader', - target='path2', - context=mock.ANY), - mock.call( - source='path3', - template_loader='my-loader', - target='path3', - context=mock.ANY), - mock.call( - source='path4', - template_loader='my-loader', - target='path4', - context=mock.ANY), - ] - self.render.assert_has_calls(calls, any_order=True) - # Assert that None was not passed to render via the context kwarg - for call in self.render.call_args_list: - self.assertTrue(call[1]['context']) - - def test_render_configs_singleton_render_with_old_style_interfaces(self): - # Test for fix to Bug #1623917 - self.patch_object(chm.charmhelpers.core.templating, 'render') - self.patch_object(chm.os_templating, - 'get_loader', - return_value='my-loader') - - class OldSkoolAdapter(object): - def __init__(self, interfaces): - pass - self.patch_object(self.target.singleton, 'adapters_class') - self.adapters_class.side_effect = OldSkoolAdapter - - self.target.singleton.render_with_interfaces( - ['interface1', 'interface2']) - - adapter_calls = [ - mock.call( - ['interface1', 'interface2'], - charm_instance=self.target.singleton), - mock.call( - ['interface1', 'interface2'])] - self.adapters_class.assert_has_calls(adapter_calls) - - calls = [ - mock.call( - source='path1', - template_loader='my-loader', - target='path1', - context=mock.ANY), - mock.call( - source='path2', - template_loader='my-loader', - target='path2', - context=mock.ANY), - mock.call( - source='path3', - template_loader='my-loader', - target='path3', - context=mock.ANY), - mock.call( - source='path4', - template_loader='my-loader', - target='path4', - context=mock.ANY), - ] - self.render.assert_has_calls(calls, any_order=True) - # Assert that None was not passed to render via the context kwarg - for call in self.render.call_args_list: - self.assertTrue(call[1]['context']) - - def test_deferred_assess_status(self): - self.patch_object(chm.hookenv, 'atexit') - s = self.target.singleton - self.patch_target('_assess_status') - s.assess_status() - self._assess_status.assert_not_called() - self.atexit.assert_called_once_with(mock.ANY) - self.atexit.reset_mock() - s.assess_status() - self.atexit.assert_not_called() - self._assess_status.assert_not_called() - - def test_assess_status_active(self): - self.patch_object(chm.hookenv, 'status_set') - self.patch_object(chm.hookenv, 'application_version_set') - # disable all of the check functions - self.patch_target('check_if_paused', return_value=(None, None)) - self.patch_target('check_interfaces', return_value=(None, None)) - self.patch_target('custom_assess_status_check', - return_value=(None, None)) - self.patch_target('check_services_running', return_value=(None, None)) - self.target._assess_status() - self.status_set.assert_called_once_with('active', 'Unit is ready') - self.application_version_set.assert_called_once_with(mock.ANY) - # check all the check functions got called - self.check_if_paused.assert_called_once_with() - self.check_interfaces.assert_called_once_with() - self.custom_assess_status_check.assert_called_once_with() - self.check_services_running.assert_called_once_with() - - def test_assess_status_paused(self): - self.patch_object(chm.hookenv, 'status_set') - self.patch_object(chm.hookenv, 'application_version_set') - # patch out _ows_check_if_paused - self.patch_object(chm.os_utils, '_ows_check_if_paused', - return_value=('paused', '123')) - self.target._assess_status() - self.status_set.assert_called_once_with('paused', '123') - self.application_version_set.assert_called_once_with(mock.ANY) - self._ows_check_if_paused.assert_called_once_with( - services=self.target.services, - ports=[1, 2, 3, 1234, 2468, 3579]) - - def test_states_to_check(self): - self.patch_target('required_relations', new=['rel1', 'rel2']) - states = self.target.states_to_check() - self.assertEqual( - states, - { - 'rel1': [ - ('rel1.connected', 'blocked', "'rel1' missing"), - ('rel1.available', 'waiting', "'rel1' incomplete") - ], - 'rel2': [ - ('rel2.connected', 'blocked', "'rel2' missing"), - ('rel2.available', 'waiting', "'rel2' incomplete") - ] - }) - # test override feature of target.states_to_check() - states = self.target.states_to_check(required_relations=['rel3']) - self.assertEqual( - states, - { - 'rel3': [ - ('rel3.connected', 'blocked', "'rel3' missing"), - ('rel3.available', 'waiting', "'rel3' incomplete") - ], - }) - - def test_assess_status_check_interfaces(self): - self.patch_object(chm.hookenv, 'status_set') - self.patch_target('check_if_paused', return_value=(None, None)) - # first check it returns None, None if there are no states - with mock.patch.object(self.target, - 'states_to_check', - return_value={}): - self.assertEqual(self.target.check_interfaces(), (None, None)) - # next check that we get back the states we think we should - self.patch_object(chm.reactive.bus, - 'get_states', - return_value={'rel1.connected': 1, }) - self.patch_target('required_relations', new=['rel1', 'rel2']) - - def my_compare(x, y): - if x is None: - x = 'unknown' - if x <= y: - return x - return y - - self.patch_object(chm.os_utils, 'workload_state_compare', - new=my_compare) - self.assertEqual(self.target.check_interfaces(), - ('blocked', "'rel1' incomplete, 'rel2' missing")) - # check that the assess_status give the same result - self.target._assess_status() - self.status_set.assert_called_once_with( - 'blocked', "'rel1' incomplete, 'rel2' missing") - - # Now check it returns None, None if all states are available - self.get_states.return_value = { - 'rel1.connected': 1, - 'rel1.available': 2, - 'rel2.connected': 3, - 'rel2.available': 4, - } - self.assertEqual(self.target.check_interfaces(), (None, None)) - - def test_check_assess_status_check_services_running(self): - # verify that the function calls _ows_check_services_running() with the - # valid information - self.patch_object(chm.os_utils, '_ows_check_services_running', - return_value=('active', 'that')) - status, message = self.target.check_services_running() - self.assertEqual((status, message), ('active', 'that')) - self._ows_check_services_running.assert_called_once_with( - services=['my-default-service', 'my-second-service'], - ports=[1, 2, 3, 1234, 2468, 3579]) - - def test_check_ports_to_check(self): - ports = { - 's1': {'k1': 3, 'k2': 4, 'k3': 5}, - 's2': {'k4': 6, 'k5': 1, 'k6': 2}, - 's3': {'k2': 4, 'k5': 1}, - } - self.assertEqual(self.target.ports_to_check(ports), - [1, 2, 3, 4, 5, 6]) - - def test_get_os_codename_package(self): - codenames = { - 'testpkg': collections.OrderedDict([ - ('2', 'mitaka'), - ('3', 'newton'), - ('4', 'ocata'), ])} - self.patch_object(chm.charmhelpers.fetch, 'apt_cache') - pkg_mock = mock.MagicMock() - self.apt_cache.return_value = { - 'testpkg': pkg_mock} - self.patch_object(chm.apt, 'upstream_version') - self.upstream_version.return_value = '3.0.0~b1' - self.assertEqual( - chm.OpenStackCharm.get_os_codename_package('testpkg', codenames), - 'newton') - # Test non-fatal fail - self.assertEqual( - chm.OpenStackCharm.get_os_codename_package('unknownpkg', - codenames, - fatal=False), - None) - # Test fatal fail - with self.assertRaises(Exception): - chm.OpenStackCharm.get_os_codename_package('unknownpkg', - codenames, - fatal=True) - - def test_get_os_version_package(self): - self.patch_target('package_codenames') - self.patch_target('get_os_codename_package', - return_value='my-series') - self.assertEqual( - self.target.get_os_version_package('testpkg'), - '2011.2') - # Test unknown codename - self.patch_target('get_os_codename_package', - return_value='unknown-series') - self.assertEqual(self.target.get_os_version_package('testpkg'), None) - - def test_openstack_upgrade_available(self): - self.patch_target('get_os_version_package') - self.patch_object(chm.os_utils, 'get_os_version_install_source') - self.patch_object(chm, 'apt') - self.patch_target('config', - new={'openstack-origin': 'cloud:natty-folsom'}) - self.get_os_version_package.return_value = 2 - self.get_os_version_install_source.return_value = 3 - self.target.openstack_upgrade_available('testpkg') - self.apt.version_compare.assert_called_once_with(3, 2) - - def test_upgrade_if_available(self): - self.patch_target('openstack_upgrade_available') - self.patch_object(chm.hookenv, 'status_set') - self.patch_target('do_openstack_pkg_upgrade') - self.patch_target('do_openstack_upgrade_config_render') - self.patch_target('do_openstack_upgrade_db_migration') - # Test no upgrade avaialble - self.openstack_upgrade_available.return_value = False - self.target.upgrade_if_available('int_list') - self.assertFalse(self.status_set.called) - self.assertFalse(self.do_openstack_pkg_upgrade.called) - self.assertFalse(self.do_openstack_upgrade_config_render.called) - self.assertFalse(self.do_openstack_upgrade_db_migration.called) - # Test upgrade avaialble - self.openstack_upgrade_available.return_value = True - self.target.upgrade_if_available('int_list') - self.status_set.assert_called_once_with('maintenance', - 'Running openstack upgrade') - self.do_openstack_pkg_upgrade.assert_called_once_with() - self.do_openstack_upgrade_config_render.assert_called_once_with( - 'int_list') - self.do_openstack_upgrade_db_migration.assert_called_once_with() - - def test_do_openstack_pkg_upgrade(self): - self.patch_target('config', - new={'openstack-origin': 'cloud:natty-kilo'}) - self.patch_object(chm.os_utils, 'get_os_codename_install_source') - self.patch_object(chm.hookenv, 'log') - self.patch_object(chm.os_utils, 'configure_installation_source') - self.patch_object(chm.charmhelpers.fetch, 'apt_update') - self.patch_object(chm.charmhelpers.fetch, 'apt_upgrade') - self.patch_object(chm.charmhelpers.fetch, 'apt_install') - self.target.do_openstack_pkg_upgrade() - self.configure_installation_source.assert_called_once_with( - 'cloud:natty-kilo') - self.apt_update.assert_called_once_with() - self.apt_upgrade.assert_called_once_with( - dist=True, fatal=True, - options=[ - '--option', 'Dpkg::Options::=--force-confnew', '--option', - 'Dpkg::Options::=--force-confdef']) - self.apt_install.assert_called_once_with( - packages=['p1', 'p2', 'p3', 'package-to-filter'], - options=[ - '--option', 'Dpkg::Options::=--force-confnew', '--option', - 'Dpkg::Options::=--force-confdef'], - fatal=True) - - def test_do_openstack_upgrade_config_render(self): - self.patch_target('render_with_interfaces') - self.target.do_openstack_upgrade_config_render('int_list') - self.render_with_interfaces.assert_called_once_with('int_list') - - def test_do_openstack_upgrade_db_migration(self): - self.patch_object(chm.hookenv, 'is_leader') - self.patch_object(chm.subprocess, 'check_call') - self.patch_object(chm.hookenv, 'log') - # Check migration not run if not leader - self.is_leader.return_value = False - self.target.do_openstack_upgrade_db_migration() - self.assertFalse(self.check_call.called) - # Check migration run on leader - self.is_leader.return_value = True - self.target.do_openstack_upgrade_db_migration() - self.check_call.assert_called_once_with(['my-sync-cmd', 'param1'])