diff --git a/charmhelpers/contrib/openstack/context.py b/charmhelpers/contrib/openstack/context.py index 9ae4582c..1248d49f 100644 --- a/charmhelpers/contrib/openstack/context.py +++ b/charmhelpers/contrib/openstack/context.py @@ -194,10 +194,50 @@ def config_flags_parser(config_flags): class OSContextGenerator(object): """Base class for all context generators.""" interfaces = [] + related = False + complete = False + missing_data = [] def __call__(self): raise NotImplementedError + def context_complete(self, ctxt): + """Check for missing data for the required context data. + Set self.missing_data if it exists and return False. + Set self.complete if no missing data and return True. + """ + # Fresh start + self.complete = False + self.missing_data = [] + for k, v in six.iteritems(ctxt): + if v is None or v == '': + if k not in self.missing_data: + self.missing_data.append(k) + + if self.missing_data: + self.complete = False + log('Missing required data: %s' % ' '.join(self.missing_data), level=INFO) + else: + self.complete = True + return self.complete + + def get_related(self): + """Check if any of the context interfaces have relation ids. + Set self.related and return True if one of the interfaces + has relation ids. + """ + # Fresh start + self.related = False + try: + for interface in self.interfaces: + if relation_ids(interface): + self.related = True + return self.related + except AttributeError as e: + log("{} {}" + "".format(self, e), 'INFO') + return self.related + class SharedDBContext(OSContextGenerator): interfaces = ['shared-db'] @@ -213,6 +253,7 @@ class SharedDBContext(OSContextGenerator): self.database = database self.user = user self.ssl_dir = ssl_dir + self.rel_name = self.interfaces[0] def __call__(self): self.database = self.database or config('database') @@ -246,6 +287,7 @@ class SharedDBContext(OSContextGenerator): password_setting = self.relation_prefix + '_password' for rid in relation_ids(self.interfaces[0]): + self.related = True for unit in related_units(rid): rdata = relation_get(rid=rid, unit=unit) host = rdata.get('db_host') @@ -257,7 +299,7 @@ class SharedDBContext(OSContextGenerator): 'database_password': rdata.get(password_setting), 'database_type': 'mysql' } - if context_complete(ctxt): + if self.context_complete(ctxt): db_ssl(rdata, ctxt, self.ssl_dir) return ctxt return {} @@ -278,6 +320,7 @@ class PostgresqlDBContext(OSContextGenerator): ctxt = {} for rid in relation_ids(self.interfaces[0]): + self.related = True for unit in related_units(rid): rel_host = relation_get('host', rid=rid, unit=unit) rel_user = relation_get('user', rid=rid, unit=unit) @@ -287,7 +330,7 @@ class PostgresqlDBContext(OSContextGenerator): 'database_user': rel_user, 'database_password': rel_passwd, 'database_type': 'postgresql'} - if context_complete(ctxt): + if self.context_complete(ctxt): return ctxt return {} @@ -348,6 +391,7 @@ class IdentityServiceContext(OSContextGenerator): ctxt['signing_dir'] = cachedir for rid in relation_ids(self.rel_name): + self.related = True for unit in related_units(rid): rdata = relation_get(rid=rid, unit=unit) serv_host = rdata.get('service_host') @@ -366,7 +410,7 @@ class IdentityServiceContext(OSContextGenerator): 'service_protocol': svc_protocol, 'auth_protocol': auth_protocol}) - if context_complete(ctxt): + if self.context_complete(ctxt): # NOTE(jamespage) this is required for >= icehouse # so a missing value just indicates keystone needs # upgrading @@ -405,6 +449,7 @@ class AMQPContext(OSContextGenerator): ctxt = {} for rid in relation_ids(self.rel_name): ha_vip_only = False + self.related = True for unit in related_units(rid): if relation_get('clustered', rid=rid, unit=unit): ctxt['clustered'] = True @@ -437,7 +482,7 @@ class AMQPContext(OSContextGenerator): ha_vip_only = relation_get('ha-vip-only', rid=rid, unit=unit) is not None - if context_complete(ctxt): + if self.context_complete(ctxt): if 'rabbit_ssl_ca' in ctxt: if not self.ssl_dir: log("Charm not setup for ssl support but ssl ca " @@ -469,7 +514,7 @@ class AMQPContext(OSContextGenerator): ctxt['oslo_messaging_flags'] = config_flags_parser( oslo_messaging_flags) - if not context_complete(ctxt): + if not self.complete: return {} return ctxt @@ -485,13 +530,15 @@ class CephContext(OSContextGenerator): log('Generating template context for ceph', level=DEBUG) mon_hosts = [] - auth = None - key = None - use_syslog = str(config('use-syslog')).lower() + ctxt = { + 'use_syslog': str(config('use-syslog')).lower() + } for rid in relation_ids('ceph'): for unit in related_units(rid): - auth = relation_get('auth', rid=rid, unit=unit) - key = relation_get('key', rid=rid, unit=unit) + if not ctxt.get('auth'): + ctxt['auth'] = relation_get('auth', rid=rid, unit=unit) + if not ctxt.get('key'): + ctxt['key'] = relation_get('key', rid=rid, unit=unit) ceph_pub_addr = relation_get('ceph-public-address', rid=rid, unit=unit) unit_priv_addr = relation_get('private-address', rid=rid, @@ -500,15 +547,12 @@ class CephContext(OSContextGenerator): ceph_addr = format_ipv6_addr(ceph_addr) or ceph_addr mon_hosts.append(ceph_addr) - ctxt = {'mon_hosts': ' '.join(sorted(mon_hosts)), - 'auth': auth, - 'key': key, - 'use_syslog': use_syslog} + ctxt['mon_hosts'] = ' '.join(sorted(mon_hosts)) if not os.path.isdir('/etc/ceph'): os.mkdir('/etc/ceph') - if not context_complete(ctxt): + if not self.context_complete(ctxt): return {} ensure_packages(['ceph-common']) @@ -1367,6 +1411,6 @@ class NetworkServiceContext(OSContextGenerator): 'auth_protocol': rdata.get('auth_protocol') or 'http', } - if context_complete(ctxt): + if self.context_complete(ctxt): return ctxt return {} diff --git a/charmhelpers/contrib/openstack/templating.py b/charmhelpers/contrib/openstack/templating.py index 021d8cf9..e5e3cb1b 100644 --- a/charmhelpers/contrib/openstack/templating.py +++ b/charmhelpers/contrib/openstack/templating.py @@ -18,7 +18,7 @@ import os import six -from charmhelpers.fetch import apt_install +from charmhelpers.fetch import apt_install, apt_update from charmhelpers.core.hookenv import ( log, ERROR, @@ -29,6 +29,7 @@ from charmhelpers.contrib.openstack.utils import OPENSTACK_CODENAMES try: from jinja2 import FileSystemLoader, ChoiceLoader, Environment, exceptions except ImportError: + apt_update(fatal=True) apt_install('python-jinja2', fatal=True) from jinja2 import FileSystemLoader, ChoiceLoader, Environment, exceptions @@ -112,7 +113,7 @@ class OSConfigTemplate(object): def complete_contexts(self): ''' - Return a list of interfaces that have atisfied contexts. + Return a list of interfaces that have satisfied contexts. ''' if self._complete_contexts: return self._complete_contexts @@ -293,3 +294,30 @@ class OSConfigRenderer(object): [interfaces.extend(i.complete_contexts()) for i in six.itervalues(self.templates)] return interfaces + + def get_incomplete_context_data(self, interfaces): + ''' + Return dictionary of relation status of interfaces and any missing + required context data. Example: + {'amqp': {'missing_data': ['rabbitmq_password'], 'related': True}, + 'zeromq-configuration': {'related': False}} + ''' + incomplete_context_data = {} + + for i in six.itervalues(self.templates): + for context in i.contexts: + for interface in interfaces: + related = False + if interface in context.interfaces: + related = context.get_related() + missing_data = context.missing_data + if missing_data: + incomplete_context_data[interface] = {'missing_data': missing_data} + if related: + if incomplete_context_data.get(interface): + incomplete_context_data[interface].update({'related': True}) + else: + incomplete_context_data[interface] = {'related': True} + else: + incomplete_context_data[interface] = {'related': False} + return incomplete_context_data diff --git a/charmhelpers/contrib/openstack/utils.py b/charmhelpers/contrib/openstack/utils.py index c98c5c9e..4d395a73 100644 --- a/charmhelpers/contrib/openstack/utils.py +++ b/charmhelpers/contrib/openstack/utils.py @@ -25,6 +25,7 @@ import sys import re import six +import traceback import yaml from charmhelpers.contrib.network import ip @@ -34,12 +35,16 @@ from charmhelpers.core import ( ) from charmhelpers.core.hookenv import ( + action_fail, + action_set, config, log as juju_log, charm_dir, INFO, relation_ids, - relation_set + relation_set, + status_set, + hook_name ) from charmhelpers.contrib.storage.linux.lvm import ( @@ -114,6 +119,7 @@ SWIFT_CODENAMES = OrderedDict([ ('2.2.1', 'kilo'), ('2.2.2', 'kilo'), ('2.3.0', 'liberty'), + ('2.4.0', 'liberty'), ]) # >= Liberty version->codename mapping @@ -142,6 +148,9 @@ PACKAGE_CODENAMES = { 'glance-common': OrderedDict([ ('11.0.0', 'liberty'), ]), + 'openstack-dashboard': OrderedDict([ + ('8.0.0', 'liberty'), + ]), } DEFAULT_LOOPBACK_SIZE = '5G' @@ -745,3 +754,217 @@ def git_yaml_value(projects_yaml, key): return projects[key] return None + + +def os_workload_status(configs, required_interfaces, charm_func=None): + """ + Decorator to set workload status based on complete contexts + """ + def wrap(f): + @wraps(f) + def wrapped_f(*args, **kwargs): + # Run the original function first + f(*args, **kwargs) + # Set workload status now that contexts have been + # acted on + set_os_workload_status(configs, required_interfaces, charm_func) + return wrapped_f + return wrap + + +def set_os_workload_status(configs, required_interfaces, charm_func=None): + """ + Set workload status based on complete contexts. + status-set missing or incomplete contexts + and juju-log details of missing required data. + charm_func is a charm specific function to run checking + for charm specific requirements such as a VIP setting. + """ + incomplete_rel_data = incomplete_relation_data(configs, required_interfaces) + state = 'active' + missing_relations = [] + incomplete_relations = [] + message = None + charm_state = None + charm_message = None + + for generic_interface in incomplete_rel_data.keys(): + related_interface = None + missing_data = {} + # Related or not? + for interface in incomplete_rel_data[generic_interface]: + if incomplete_rel_data[generic_interface][interface].get('related'): + related_interface = interface + missing_data = incomplete_rel_data[generic_interface][interface].get('missing_data') + # No relation ID for the generic_interface + if not related_interface: + juju_log("{} relation is missing and must be related for " + "functionality. ".format(generic_interface), 'WARN') + state = 'blocked' + if generic_interface not in missing_relations: + missing_relations.append(generic_interface) + else: + # Relation ID exists but no related unit + if not missing_data: + # Edge case relation ID exists but departing + if ('departed' in hook_name() or 'broken' in hook_name()) \ + and related_interface in hook_name(): + state = 'blocked' + if generic_interface not in missing_relations: + missing_relations.append(generic_interface) + juju_log("{} relation's interface, {}, " + "relationship is departed or broken " + "and is required for functionality." + "".format(generic_interface, related_interface), "WARN") + # Normal case relation ID exists but no related unit + # (joining) + else: + juju_log("{} relations's interface, {}, is related but has " + "no units in the relation." + "".format(generic_interface, related_interface), "INFO") + # Related unit exists and data missing on the relation + else: + juju_log("{} relation's interface, {}, is related awaiting " + "the following data from the relationship: {}. " + "".format(generic_interface, related_interface, + ", ".join(missing_data)), "INFO") + if state != 'blocked': + state = 'waiting' + if generic_interface not in incomplete_relations \ + and generic_interface not in missing_relations: + incomplete_relations.append(generic_interface) + + if missing_relations: + message = "Missing relations: {}".format(", ".join(missing_relations)) + if incomplete_relations: + message += "; incomplete relations: {}" \ + "".format(", ".join(incomplete_relations)) + state = 'blocked' + elif incomplete_relations: + message = "Incomplete relations: {}" \ + "".format(", ".join(incomplete_relations)) + state = 'waiting' + + # Run charm specific checks + if charm_func: + charm_state, charm_message = charm_func(configs) + if charm_state != 'active' and charm_state != 'unknown': + state = workload_state_compare(state, charm_state) + if message: + message = "{} {}".format(message, charm_message) + else: + message = charm_message + + # Set to active if all requirements have been met + if state == 'active': + message = "Unit is ready" + juju_log(message, "INFO") + + status_set(state, message) + + +def workload_state_compare(current_workload_state, workload_state): + """ Return highest priority of two states""" + hierarchy = {'unknown': -1, + 'active': 0, + 'maintenance': 1, + 'waiting': 2, + 'blocked': 3, + } + + if hierarchy.get(workload_state) is None: + workload_state = 'unknown' + if hierarchy.get(current_workload_state) is None: + current_workload_state = 'unknown' + + # Set workload_state based on hierarchy of statuses + if hierarchy.get(current_workload_state) > hierarchy.get(workload_state): + return current_workload_state + else: + return workload_state + + +def incomplete_relation_data(configs, required_interfaces): + """ + Check complete contexts against required_interfaces + Return dictionary of incomplete relation data. + + configs is an OSConfigRenderer object with configs registered + + required_interfaces is a dictionary of required general interfaces + with dictionary values of possible specific interfaces. + Example: + required_interfaces = {'database': ['shared-db', 'pgsql-db']} + + The interface is said to be satisfied if anyone of the interfaces in the + list has a complete context. + + Return dictionary of incomplete or missing required contexts with relation + status of interfaces and any missing data points. Example: + {'message': + {'amqp': {'missing_data': ['rabbitmq_password'], 'related': True}, + 'zeromq-configuration': {'related': False}}, + 'identity': + {'identity-service': {'related': False}}, + 'database': + {'pgsql-db': {'related': False}, + 'shared-db': {'related': True}}} + """ + complete_ctxts = configs.complete_contexts() + incomplete_relations = [] + for svc_type in required_interfaces.keys(): + # Avoid duplicates + found_ctxt = False + for interface in required_interfaces[svc_type]: + if interface in complete_ctxts: + found_ctxt = True + if not found_ctxt: + incomplete_relations.append(svc_type) + incomplete_context_data = {} + for i in incomplete_relations: + incomplete_context_data[i] = configs.get_incomplete_context_data(required_interfaces[i]) + return incomplete_context_data + + +def do_action_openstack_upgrade(package, upgrade_callback, configs): + """Perform action-managed OpenStack upgrade. + + Upgrades packages to the configured openstack-origin version and sets + the corresponding action status as a result. + + If the charm was installed from source we cannot upgrade it. + For backwards compatibility a config flag (action-managed-upgrade) must + be set for this code to run, otherwise a full service level upgrade will + fire on config-changed. + + @param package: package name for determining if upgrade available + @param upgrade_callback: function callback to charm's upgrade function + @param configs: templating object derived from OSConfigRenderer class + + @return: True if upgrade successful; False if upgrade failed or skipped + """ + ret = False + + if git_install_requested(): + action_set({'outcome': 'installed from source, skipped upgrade.'}) + else: + if openstack_upgrade_available(package): + if config('action-managed-upgrade'): + juju_log('Upgrading OpenStack release') + + try: + upgrade_callback(configs=configs) + action_set({'outcome': 'success, upgrade completed.'}) + ret = True + except: + action_set({'outcome': 'upgrade failed, see traceback.'}) + action_set({'traceback': traceback.format_exc()}) + action_fail('do_openstack_upgrade resulted in an ' + 'unexpected error') + else: + action_set({'outcome': 'action-managed-upgrade config is ' + 'False, skipped upgrade.'}) + else: + action_set({'outcome': 'no upgrade available.'}) + + return ret diff --git a/hooks/keystone_hooks.py b/hooks/keystone_hooks.py index 516df25b..8503e4ca 100755 --- a/hooks/keystone_hooks.py +++ b/hooks/keystone_hooks.py @@ -24,6 +24,7 @@ from charmhelpers.core.hookenv import ( relation_set, related_units, unit_get, + status_set, ) from charmhelpers.core.host import ( @@ -45,7 +46,8 @@ from charmhelpers.contrib.openstack.utils import ( configure_installation_source, git_install_requested, openstack_upgrade_available, - sync_db_with_multi_ipv6_addresses + sync_db_with_multi_ipv6_addresses, + os_workload_status, ) from keystone_utils import ( @@ -80,6 +82,8 @@ from keystone_utils import ( force_ssl_sync, filter_null, ensure_ssl_dirs, + REQUIRED_INTERFACES, + check_ha_settings, ) from charmhelpers.contrib.hahelpers.cluster import ( @@ -113,20 +117,26 @@ CONFIGS = register_configs() @hooks.hook('install.real') +@os_workload_status(CONFIGS, REQUIRED_INTERFACES, charm_func=check_ha_settings) def install(): + status_set('maintenance', 'Executing pre-install') execd_preinstall() configure_installation_source(config('openstack-origin')) + status_set('maintenance', 'Installing apt packages') apt_update() apt_install(determine_packages(), fatal=True) + status_set('maintenance', 'Git install') git_install(config('openstack-origin-git')) @hooks.hook('config-changed') +@os_workload_status(CONFIGS, REQUIRED_INTERFACES, charm_func=check_ha_settings) @restart_on_change(restart_map()) @synchronize_ca_if_changed(fatal=True) def config_changed(): if config('prefer-ipv6'): + status_set('maintenance', 'configuring ipv6') setup_ipv6() sync_db_with_multi_ipv6_addresses(config('database'), config('database-user')) @@ -139,9 +149,11 @@ def config_changed(): if git_install_requested(): if config_value_changed('openstack-origin-git'): + status_set('maintenance', 'Running Git install') git_install(config('openstack-origin-git')) else: if openstack_upgrade_available('keystone'): + status_set('maintenance', 'Running openstack upgrade') do_openstack_upgrade(configs=CONFIGS) # Ensure ssl dir exists and is unison-accessible @@ -196,6 +208,7 @@ def initialise_pki(): @hooks.hook('shared-db-relation-joined') +@os_workload_status(CONFIGS, REQUIRED_INTERFACES, charm_func=check_ha_settings) def db_joined(): if is_relation_made('pgsql-db'): # error, postgresql is used @@ -214,6 +227,7 @@ def db_joined(): @hooks.hook('pgsql-db-relation-joined') +@os_workload_status(CONFIGS, REQUIRED_INTERFACES, charm_func=check_ha_settings) def pgsql_db_joined(): if is_relation_made('shared-db'): # raise error @@ -252,6 +266,7 @@ def update_all_identity_relation_units_force_sync(): @hooks.hook('shared-db-relation-changed') +@os_workload_status(CONFIGS, REQUIRED_INTERFACES, charm_func=check_ha_settings) @restart_on_change(restart_map()) @synchronize_ca_if_changed() def db_changed(): @@ -275,6 +290,7 @@ def db_changed(): @hooks.hook('pgsql-db-relation-changed') +@os_workload_status(CONFIGS, REQUIRED_INTERFACES, charm_func=check_ha_settings) @restart_on_change(restart_map()) @synchronize_ca_if_changed() def pgsql_db_changed(): @@ -474,6 +490,7 @@ def leader_settings_changed(): @hooks.hook('ha-relation-joined') +@os_workload_status(CONFIGS, REQUIRED_INTERFACES, charm_func=check_ha_settings) def ha_joined(relation_id=None): cluster_config = get_hacluster_config() resources = { @@ -531,6 +548,7 @@ def ha_joined(relation_id=None): @hooks.hook('ha-relation-changed') @restart_on_change(restart_map()) @synchronize_ca_if_changed() +@os_workload_status(CONFIGS, REQUIRED_INTERFACES, charm_func=check_ha_settings) def ha_changed(): CONFIGS.write_all() @@ -576,6 +594,7 @@ def configure_https(): @restart_on_change(restart_map(), stopstart=True) @synchronize_ca_if_changed() def upgrade_charm(): + status_set('maintenance', 'Installing apt packages') apt_install(filter_installed_packages(determine_packages())) unison.ssh_authorized_peers(user=SSH_USER, group='juju_keystone', diff --git a/hooks/keystone_utils.py b/hooks/keystone_utils.py index fb019cc3..3b2875cc 100644 --- a/hooks/keystone_utils.py +++ b/hooks/keystone_utils.py @@ -24,6 +24,7 @@ from charmhelpers.contrib.hahelpers.cluster import( determine_api_port, https, peer_units, + get_hacluster_config, ) from charmhelpers.contrib.openstack import context, templating @@ -271,6 +272,12 @@ valid_services = { } } +# The interface is said to be satisfied if anyone of the interfaces in the +# list has a complete context. +REQUIRED_INTERFACES = { + 'database': ['shared-db', 'pgsql-db'], +} + def filter_null(settings, null='__null__'): """Replace null values with None in provided settings dict. @@ -1775,3 +1782,16 @@ def git_post_install(projects_yaml): perms=0o644, templates_dir=templates_dir) service_restart('keystone') + + +def check_ha_settings(configs): + if relation_ids('ha'): + try: + get_hacluster_config() + return 'active', 'hacluster configs complete.' + except: + return ('blocked', + 'hacluster missing configuration: ' + 'vip, vip_iface, vip_cidr') + else: + return 'unknown', 'No ha clustering' diff --git a/unit_tests/test_utils.py b/unit_tests/test_utils.py index 526a61f7..b7c525a2 100644 --- a/unit_tests/test_utils.py +++ b/unit_tests/test_utils.py @@ -6,6 +6,9 @@ import yaml from contextlib import contextmanager from mock import patch, MagicMock +patch('charmhelpers.contrib.openstack.utils.set_os_workload_status').start() +patch('charmhelpers.core.hookenv.status_set').start() + def load_config(): '''Walk backwords from __file__ looking for config.yaml,