diff --git a/actions.yaml b/actions.yaml index 7b59a677..03a2cde7 100644 --- a/actions.yaml +++ b/actions.yaml @@ -1,2 +1,4 @@ git-reinstall: description: Reinstall openstack-dashboard from the openstack-origin-git repositories. +openstack-upgrade: + description: Perform openstack upgrades. Config option action-managed-upgrade must be set to True. diff --git a/actions/openstack-upgrade b/actions/openstack-upgrade new file mode 120000 index 00000000..61793013 --- /dev/null +++ b/actions/openstack-upgrade @@ -0,0 +1 @@ +openstack_upgrade.py \ No newline at end of file diff --git a/actions/openstack_upgrade.py b/actions/openstack_upgrade.py new file mode 100755 index 00000000..902ca133 --- /dev/null +++ b/actions/openstack_upgrade.py @@ -0,0 +1,34 @@ +#!/usr/bin/python +import sys + +sys.path.append('hooks/') + +from charmhelpers.contrib.openstack.utils import ( + do_action_openstack_upgrade, +) + +from horizon_utils import ( + do_openstack_upgrade, +) + +from horizon_hooks import ( + config_changed, + CONFIGS, +) + + +def openstack_upgrade(): + """Upgrade packages to config-set Openstack version. + + If the charm was installed from source we cannot upgrade it. + For backwards compatibility a config flag must be set for this + code to run, otherwise a full service level upgrade will fire + on config-changed.""" + + if do_action_openstack_upgrade('openstack-dashboard', + do_openstack_upgrade, + CONFIGS): + config_changed() + +if __name__ == '__main__': + openstack_upgrade() diff --git a/config.yaml b/config.yaml index 42ffffde..374293a0 100644 --- a/config.yaml +++ b/config.yaml @@ -153,6 +153,14 @@ options: In order for this charm to function correctly, the privacy extension must be disabled and a non-temporary address must be configured/available on your network interface. + endpoint-type: + type: string + default: + description: | + Specifies the endpoint types to use for endpoints in the Keystone + service catalog. Valid values are 'publicURL', 'internalURL', + and 'adminURL'. Both the primary and secondary endpoint types can + be specified by providing multiple comma delimited values. nagios_context: default: "juju" type: string @@ -175,4 +183,13 @@ options: description: | A comma-separated list of nagios servicegroups. If left empty, the nagios_context will be used as the servicegroup. - + action-managed-upgrade: + type: boolean + default: False + description: | + If True enables openstack upgrades for this charm via juju actions. + You will still need to set openstack-origin to the new repository but + instead of an upgrade running automatically across all units, it will + wait for you to execute the openstack-upgrade action for this charm on + each unit. If False it will revert to existing behavior of upgrading + all units on config change. diff --git a/hooks/charmhelpers/contrib/openstack/utils.py b/hooks/charmhelpers/contrib/openstack/utils.py index fa3e4294..2f5280e6 100644 --- a/hooks/charmhelpers/contrib/openstack/utils.py +++ b/hooks/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,6 +35,8 @@ from charmhelpers.core import ( ) from charmhelpers.core.hookenv import ( + action_fail, + action_set, config, log as juju_log, charm_dir, @@ -114,6 +117,7 @@ SWIFT_CODENAMES = OrderedDict([ ('2.2.1', 'kilo'), ('2.2.2', 'kilo'), ('2.3.0', 'liberty'), + ('2.4.0', 'liberty'), ]) # >= Liberty version->codename mapping @@ -748,3 +752,47 @@ def git_yaml_value(projects_yaml, key): return projects[key] return None + + +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/dashboard-plugin-relation-changed b/hooks/dashboard-plugin-relation-changed new file mode 120000 index 00000000..3195386e --- /dev/null +++ b/hooks/dashboard-plugin-relation-changed @@ -0,0 +1 @@ +horizon_hooks.py \ No newline at end of file diff --git a/hooks/horizon_contexts.py b/hooks/horizon_contexts.py index 7200e662..a4ca52e6 100644 --- a/hooks/horizon_contexts.py +++ b/hooks/horizon_contexts.py @@ -6,7 +6,8 @@ from charmhelpers.core.hookenv import ( relation_get, local_unit, unit_get, - log + log, + ERROR, ) from charmhelpers.contrib.openstack.context import ( OSContextGenerator, @@ -28,6 +29,12 @@ from charmhelpers.core.host import pwgen from base64 import b64decode import os +VALID_ENDPOINT_TYPES = { + 'PUBLICURL': 'publicURL', + 'INTERNALURL': 'internalURL', + 'ADMINURL': 'adminURL', +} + class HorizonHAProxyContext(HAProxyContext): def __call__(self): @@ -69,6 +76,22 @@ class HorizonHAProxyContext(HAProxyContext): class IdentityServiceContext(OSContextGenerator): interfaces = ['identity-service'] + def normalize(self, endpoint_type): + """Normalizes the endpoint type values. + + :param endpoint_type (string): the endpoint type to normalize. + :raises: Exception if the endpoint type is not valid. + :return (string): the normalized form of the endpoint type. + """ + normalized_form = VALID_ENDPOINT_TYPES.get(endpoint_type.upper(), None) + if not normalized_form: + msg = ('Endpoint type specified %s is not a valid' + ' endpoint type' % endpoint_type) + log(msg, ERROR) + raise Exception(msg) + + return normalized_form + def __call__(self): log('Generating template context for identity-service') ctxt = {} @@ -106,6 +129,21 @@ class IdentityServiceContext(OSContextGenerator): avail_regions = map(lambda r: {'endpoint': r[0], 'title': r[1]}, regions) ctxt['regions'] = sorted(avail_regions) + + # Allow the endpoint types to be specified via a config parameter. + # The config parameter accepts either: + # 1. a single endpoint type to be specified, in which case the + # primary endpoint is configured + # 2. a list of endpoint types, in which case the primary endpoint + # is taken as the first entry and the secondary endpoint is + # taken as the second entry. All subsequent entries are ignored. + ep_types = config('endpoint-type') + if ep_types: + ep_types = [self.normalize(e) for e in ep_types.split(',')] + ctxt['primary_endpoint'] = ep_types[0] + if len(ep_types) > 1: + ctxt['secondary_endpoint'] = ep_types[1] + return ctxt @@ -172,3 +210,28 @@ class RouterSettingContext(OSContextGenerator): 'disable_router': False if config('profile') in ['cisco'] else True } return ctxt + + +class LocalSettingsContext(OSContextGenerator): + def __call__(self): + ''' Additional config stanzas to be appended to local_settings.py ''' + + relations = [] + + for rid in relation_ids("plugin"): + try: + unit = related_units(rid)[0] + except IndexError: + pass + else: + rdata = relation_get(unit=unit, rid=rid) + if set(('local-settings', 'priority')) <= set(rdata.keys()): + relations.append((unit, rdata)) + + ctxt = { + 'settings': [ + '# {0}\n{1}'.format(u, rd['local-settings']) + for u, rd in sorted(relations, + key=lambda r: r[1]['priority'])] + } + return ctxt diff --git a/hooks/horizon_hooks.py b/hooks/horizon_hooks.py index e3ffeda4..b6d2df85 100755 --- a/hooks/horizon_hooks.py +++ b/hooks/horizon_hooks.py @@ -58,7 +58,7 @@ hooks = Hooks() CONFIGS = register_configs() -@hooks.hook('install') +@hooks.hook('install.real') def install(): execd_preinstall() configure_installation_source(config('openstack-origin')) @@ -106,7 +106,7 @@ def config_changed(): if git_install_requested(): if config_value_changed('openstack-origin-git'): git_install(config('openstack-origin-git')) - else: + elif not config('action-managed-upgrade'): if openstack_upgrade_available('openstack-dashboard'): do_openstack_upgrade(configs=CONFIGS) @@ -247,16 +247,23 @@ def update_nrpe_config(): @hooks.hook('dashboard-plugin-relation-joined') -def update_dashboard_plugin(rel_id=None): +def plugin_relation_joined(rel_id=None): if git_install_requested(): bin_path = git_pip_venv_dir(config('openstack-origin-git')) else: bin_path = '/usr/bin' - relation_set(relation_id=rel_id, + relation_set(release=os_release("openstack-dashboard"), + relation_id=rel_id, bin_path=bin_path, openstack_dir=INSTALL_DIR) +@hooks.hook('dashboard-plugin-relation-changed') +@restart_on_change(restart_map()) +def update_plugin_config(): + CONFIGS.write(LOCAL_SETTINGS) + + def main(): try: hooks.execute(sys.argv) diff --git a/hooks/horizon_utils.py b/hooks/horizon_utils.py index f7237c18..d5454ffe 100644 --- a/hooks/horizon_utils.py +++ b/hooks/horizon_utils.py @@ -101,7 +101,8 @@ CONFIG_FILES = OrderedDict([ (LOCAL_SETTINGS, { 'hook_contexts': [horizon_contexts.HorizonContext(), horizon_contexts.IdentityServiceContext(), - context.SyslogContext()], + context.SyslogContext(), + horizon_contexts.LocalSettingsContext()], 'services': ['apache2'] }), (APACHE_CONF, { @@ -262,12 +263,12 @@ def setup_ipv6(): raise Exception("IPv6 is not supported in the charms for Ubuntu " "versions less than Trusty 14.04") - # NOTE(xianghui): Need to install haproxy(1.5.3) from trusty-backports - # to support ipv6 address, so check is required to make sure not - # breaking other versions, IPv6 only support for >= Trusty - if ubuntu_rel == 'trusty': - add_source('deb http://archive.ubuntu.com/ubuntu trusty-backports' - ' main') + # Need haproxy >= 1.5.3 for ipv6 so for Trusty if we are <= Kilo we need to + # use trusty-backports otherwise we can use the UCA. + os_pkg = 'openstack-dashboard' + if ubuntu_rel == 'trusty' and os_release(os_pkg) < 'liberty': + add_source('deb http://archive.ubuntu.com/ubuntu trusty-backports ' + 'main') apt_update() apt_install('haproxy/trusty-backports', fatal=True) diff --git a/hooks/install b/hooks/install deleted file mode 120000 index 3195386e..00000000 --- a/hooks/install +++ /dev/null @@ -1 +0,0 @@ -horizon_hooks.py \ No newline at end of file diff --git a/hooks/install b/hooks/install new file mode 100755 index 00000000..83a9d3ce --- /dev/null +++ b/hooks/install @@ -0,0 +1,20 @@ +#!/bin/bash +# Wrapper to deal with newer Ubuntu versions that don't have py2 installed +# by default. + +declare -a DEPS=('apt' 'netaddr' 'netifaces' 'pip' 'yaml') + +check_and_install() { + pkg="${1}-${2}" + if ! dpkg -s ${pkg} 2>&1 > /dev/null; then + apt-get -y install ${pkg} + fi +} + +PYTHON="python" + +for dep in ${DEPS[@]}; do + check_and_install ${PYTHON} ${dep} +done + +exec ./hooks/install.real diff --git a/hooks/install.real b/hooks/install.real new file mode 120000 index 00000000..3195386e --- /dev/null +++ b/hooks/install.real @@ -0,0 +1 @@ +horizon_hooks.py \ No newline at end of file diff --git a/metadata.yaml b/metadata.yaml index 165f7615..c1cfc779 100644 --- a/metadata.yaml +++ b/metadata.yaml @@ -21,6 +21,9 @@ requires: ha: interface: hacluster scope: container + dashboard-plugin: + interface: dashboard-plugin + scope: container peers: cluster: interface: openstack-dashboard-ha diff --git a/templates/essex/local_settings.py b/templates/essex/local_settings.py index 99c459d9..e06c9408 100644 --- a/templates/essex/local_settings.py +++ b/templates/essex/local_settings.py @@ -118,3 +118,5 @@ LOGGING = { } } } + +{{ settings|join('\n\n') }} diff --git a/templates/folsom/local_settings.py b/templates/folsom/local_settings.py index 2ff61fa9..f6796303 100644 --- a/templates/folsom/local_settings.py +++ b/templates/folsom/local_settings.py @@ -167,3 +167,5 @@ LOGIN_REDIRECT_URL='{{ webroot }}' # offline compression by default. To enable online compression, install # the node-less package and enable the following option. COMPRESS_OFFLINE = {{ compress_offline }} + +{{ settings|join('\n\n') }} \ No newline at end of file diff --git a/templates/grizzly/local_settings.py b/templates/grizzly/local_settings.py index 4df94e31..d87b84c1 100644 --- a/templates/grizzly/local_settings.py +++ b/templates/grizzly/local_settings.py @@ -263,3 +263,5 @@ LOGGING = { } } } + +{{ settings|join('\n\n') }} diff --git a/templates/havana/local_settings.py b/templates/havana/local_settings.py index b6a9ada4..3557f554 100644 --- a/templates/havana/local_settings.py +++ b/templates/havana/local_settings.py @@ -483,3 +483,5 @@ COMPRESS_OFFLINE = {{ compress_offline }} # installations should have this set accordingly. For more information # see https://docs.djangoproject.com/en/dev/ref/settings/. ALLOWED_HOSTS = '*' + +{{ settings|join('\n\n') }} \ No newline at end of file diff --git a/templates/icehouse/local_settings.py b/templates/icehouse/local_settings.py index 490d5340..52dbd1f3 100644 --- a/templates/icehouse/local_settings.py +++ b/templates/icehouse/local_settings.py @@ -212,6 +212,9 @@ IMAGE_CUSTOM_PROPERTY_TITLES = { # in the Keystone service catalog. Use this setting when Horizon is running # external to the OpenStack environment. The default is 'publicURL'. #OPENSTACK_ENDPOINT_TYPE = "publicURL" +{% if primary_endpoint -%} +OPENSTACK_ENDPOINT_TYPE = {{ primary_endpoint }} +{% endif -%} # SECONDARY_ENDPOINT_TYPE specifies the fallback endpoint type to use in the # case that OPENSTACK_ENDPOINT_TYPE is not present in the endpoints @@ -219,6 +222,9 @@ IMAGE_CUSTOM_PROPERTY_TITLES = { # external to the OpenStack environment. The default is None. This # value should differ from OPENSTACK_ENDPOINT_TYPE if used. #SECONDARY_ENDPOINT_TYPE = "publicURL" +{% if secondary_endpoint -%} +SECONDARY_ENDPOINT_TYPE = {{ secondary_endpoint }} +{% endif -%} # The number of objects (Swift containers/objects or images) to display # on a single page before providing a paging element (a "more" link) @@ -514,3 +520,5 @@ COMPRESS_OFFLINE = {{ compress_offline }} # installations should have this set accordingly. For more information # see https://docs.djangoproject.com/en/dev/ref/settings/. ALLOWED_HOSTS = '*' + +{{ settings|join('\n\n') }} \ No newline at end of file diff --git a/templates/juno/local_settings.py b/templates/juno/local_settings.py index 50415f8a..758e4ad7 100644 --- a/templates/juno/local_settings.py +++ b/templates/juno/local_settings.py @@ -250,6 +250,9 @@ IMAGE_RESERVED_CUSTOM_PROPERTIES = [] # in the Keystone service catalog. Use this setting when Horizon is running # external to the OpenStack environment. The default is 'publicURL'. #OPENSTACK_ENDPOINT_TYPE = "publicURL" +{% if primary_endpoint -%} +OPENSTACK_ENDPOINT_TYPE = {{ primary_endpoint }} +{% endif -%} # SECONDARY_ENDPOINT_TYPE specifies the fallback endpoint type to use in the # case that OPENSTACK_ENDPOINT_TYPE is not present in the endpoints @@ -257,6 +260,9 @@ IMAGE_RESERVED_CUSTOM_PROPERTIES = [] # external to the OpenStack environment. The default is None. This # value should differ from OPENSTACK_ENDPOINT_TYPE if used. #SECONDARY_ENDPOINT_TYPE = "publicURL" +{% if secondary_endpoint -%} +SECONDARY_ENDPOINT_TYPE = {{ secondary_endpoint }} +{% endif -%} # The number of objects (Swift containers/objects or images) to display # on a single page before providing a paging element (a "more" link) @@ -619,3 +625,5 @@ LOGIN_REDIRECT_URL='{{ webroot }}' # installations should have this set accordingly. For more information # see https://docs.djangoproject.com/en/dev/ref/settings/. ALLOWED_HOSTS = '*' + +{{ settings|join('\n\n') }} \ No newline at end of file diff --git a/unit_tests/test_actions_openstack_upgrade.py b/unit_tests/test_actions_openstack_upgrade.py new file mode 100644 index 00000000..f76674ec --- /dev/null +++ b/unit_tests/test_actions_openstack_upgrade.py @@ -0,0 +1,53 @@ +from mock import patch +import os + +os.environ['JUJU_UNIT_NAME'] = 'openstack-dashboard' + +with patch('horizon_utils.register_configs') as register_configs: + import openstack_upgrade + +from test_utils import ( + CharmTestCase +) + +TO_PATCH = [ + 'do_openstack_upgrade', + 'config_changed', +] + + +class TestHorizonUpgradeActions(CharmTestCase): + + def setUp(self): + super(TestHorizonUpgradeActions, self).setUp(openstack_upgrade, + TO_PATCH) + + @patch('charmhelpers.contrib.openstack.utils.config') + @patch('charmhelpers.contrib.openstack.utils.action_set') + @patch('charmhelpers.contrib.openstack.utils.git_install_requested') + @patch('charmhelpers.contrib.openstack.utils.openstack_upgrade_available') + def test_openstack_upgrade_true(self, upgrade_avail, git_requested, + action_set, config): + git_requested.return_value = False + upgrade_avail.return_value = True + config.return_value = True + + openstack_upgrade.openstack_upgrade() + + self.assertTrue(self.do_openstack_upgrade.called) + self.assertTrue(self.config_changed.called) + + @patch('charmhelpers.contrib.openstack.utils.config') + @patch('charmhelpers.contrib.openstack.utils.action_set') + @patch('charmhelpers.contrib.openstack.utils.git_install_requested') + @patch('charmhelpers.contrib.openstack.utils.openstack_upgrade_available') + def test_openstack_upgrade_false(self, upgrade_avail, git_requested, + action_set, config): + git_requested.return_value = False + upgrade_avail.return_value = True + config.return_value = False + + openstack_upgrade.openstack_upgrade() + + self.assertFalse(self.do_openstack_upgrade.called) + self.assertFalse(self.config_changed.called) diff --git a/unit_tests/test_horizon_contexts.py b/unit_tests/test_horizon_contexts.py index b7ad9765..a844f1b5 100644 --- a/unit_tests/test_horizon_contexts.py +++ b/unit_tests/test_horizon_contexts.py @@ -212,6 +212,22 @@ class TestHorizonContexts(CharmTestCase): {'endpoint': 'http://foo:5000/v2.0', 'title': 'regionTwo'}]}) + def test_IdentityServiceContext_endpoint_type(self): + self.test_config.set('endpoint-type', 'internalURL') + self.assertEqual(horizon_contexts.IdentityServiceContext()(), + {'primary_endpoint': 'internalURL'}) + + def test_IdentityServiceContext_multi_endpoint_types(self): + self.test_config.set('endpoint-type', 'internalURL,publicURL') + self.assertEqual(horizon_contexts.IdentityServiceContext()(), + {'primary_endpoint': 'internalURL', + 'secondary_endpoint': 'publicURL'}) + + def test_IdentityServiceContext_invalid_endpoint_type(self): + self.test_config.set('endpoint-type', 'this_is_bad') + with self.assertRaises(Exception): + horizon_contexts.IdentityServiceContext()() + def test_HorizonHAProxyContext_no_cluster(self): self.relation_ids.return_value = [] self.local_unit.return_value = 'openstack-dashboard/0' @@ -251,3 +267,18 @@ class TestHorizonContexts(CharmTestCase): self.test_config.set('profile', None) self.assertEquals(horizon_contexts.RouterSettingContext()(), {'disable_router': True, }) + + def test_LocalSettingsContext(self): + self.relation_ids.return_value = ['plugin:0', 'plugin-too:0'] + self.related_units.side_effect = [['horizon-plugin/0'], + ['horizon-plugin-too/0']] + self.relation_get.side_effect = [{'priority': 99, + 'local-settings': 'FOO = True'}, + {'priority': 60, + 'local-settings': 'BAR = False'}] + + self.assertEquals(horizon_contexts.LocalSettingsContext()(), + {'settings': ['# horizon-plugin-too/0\n' + 'BAR = False', + '# horizon-plugin/0\n' + 'FOO = True']})