diff --git a/config.yaml b/config.yaml index d3011422..30a30864 100644 --- a/config.yaml +++ b/config.yaml @@ -512,3 +512,19 @@ options: this option sets True as the default value, which is consistent with the default value 'WSGISocketRotation On' in Apache. This option should be used with caution. Please read the Apache doc page for more information. + extra-regions: + type: string + default: "{}" + description: | + Define extra regions to register in the region selector. + Only use this if it's not possible to integrate the keystone unit with juju. + It must be a json dictionary where the keys are region names, + and the values are keystone endpoint urls. + + Example: + + { + "cluster2": "https://cluster2.example.com/identity/v3", + "another cluster": "https://another.example.com/identity/v3" + } + diff --git a/hooks/horizon_contexts.py b/hooks/horizon_contexts.py index 18879a0a..79fd720c 100644 --- a/hooks/horizon_contexts.py +++ b/hooks/horizon_contexts.py @@ -16,6 +16,7 @@ import functools import json +from typing import Set, Tuple from charmhelpers.core.hookenv import ( config, @@ -55,6 +56,25 @@ SSL_CERT_FILE = '/etc/apache2/ssl/horizon/cert_dashboard' SSL_KEY_FILE = '/etc/apache2/ssl/horizon/key_dashboard' +def get_extra_regions() -> Set[Tuple[str, str]]: + """ + Get extra regions from config, as a set of (name, url) tuples + + Raises ValueError if parsing fails. + """ + extra_regions = set() + try: + extra_regions_config = json.loads(config("extra-regions")) + for title, endpoint in extra_regions_config.items(): + if isinstance(title, str) and isinstance(endpoint, str): + extra_regions.add((endpoint, title)) + else: + raise ValueError("keys and values must be strings") + except Exception as e: + raise ValueError(f"failed parsing extra-regions config: {e!r}") + return extra_regions + + class HorizonHAProxyContext(OSContextGenerator): def __call__(self): ''' @@ -203,6 +223,14 @@ class IdentityServiceContext(OSContextGenerator): if len(ctxt) == 0: ctxt = local_ctxt + try: + regions.update(get_extra_regions()) + except ValueError as e: + log( + f"Ignoring 'extra-regions' config, it is invalid. Err: {e}", + WARNING + ) + if len(regions) > 1: avail_regions = map(lambda r: {'endpoint': r[0], 'title': r[1]}, regions) diff --git a/hooks/horizon_hooks.py b/hooks/horizon_hooks.py index e4f76953..d5e08382 100755 --- a/hooks/horizon_hooks.py +++ b/hooks/horizon_hooks.py @@ -114,6 +114,8 @@ from hooks.horizon_utils import ( update_plugin_packages_in_kv, ) +from hooks.horizon_contexts import get_extra_regions + hooks = Hooks() # Note that CONFIGS is now set up via resolve_CONFIGS so that it is not a @@ -227,6 +229,12 @@ def config_changed(): application_dashboard_relation_changed() dashboard_relation_changed() + # Provide a message to the user if extra regions config is invalid + try: + get_extra_regions() + except ValueError: + status_set("blocked", "Invalid 'extra-regions' config value") + @hooks.hook('identity-service-relation-joined') def keystone_joined(rel_id=None): diff --git a/unit_tests/test_horizon_contexts.py b/unit_tests/test_horizon_contexts.py index a719c39d..7e960e40 100644 --- a/unit_tests/test_horizon_contexts.py +++ b/unit_tests/test_horizon_contexts.py @@ -1071,6 +1071,68 @@ class TestHorizonContexts(CharmTestCase): {'endpoint': 'http://foo:5000/v2.0', 'title': 'regionTwo'}]}) + @patch("hooks.horizon_contexts.format_ipv6_addr") + def test_IdentityServiceContext_multi_region_with_extra( + self, mock_format_ipv6_addr + ): + mock_format_ipv6_addr.side_effect = lambda x: x + self.relation_ids.return_value = ['foo'] + self.related_units.return_value = ['bar', 'baz'] + self.relation_get.side_effect = self.test_relation.get + self.test_relation.set({'service_host': 'foo', 'service_port': 5000, + 'internal_host': 'bar', 'internal_port': 5001, + 'region': 'regionOne regionTwo'}) + self.test_config.set( + 'extra-regions', '{"regionThree": "http://example.com/v3"}' + ) + self.maxDiff = None + self.context_complete.return_value = True + self.assertEqual( + with_regions_sorted(horizon_contexts.IdentityServiceContext()), + {'service_host': 'foo', 'service_port': 5000, + 'service_protocol': 'http', 'api_version': '2', + 'internal_host': 'bar', 'internal_port': 5001, + 'internal_protocol': 'http', + 'ks_host': 'foo', 'ks_port': 5000, + 'ks_protocol': 'http', + 'ks_endpoint_path': 'v2.0', + 'default_role': 'member', + 'regions': [{'endpoint': 'http://foo:5000/v2.0', + 'title': 'regionOne'}, + {'endpoint': 'http://example.com/v3', + 'title': 'regionThree'}, + {'endpoint': 'http://foo:5000/v2.0', + 'title': 'regionTwo'}]}) + + @patch("hooks.horizon_contexts.format_ipv6_addr") + def test_IdentityServiceContext_multi_region_with_invalid_extra_regions( + self, mock_format_ipv6_addr + ): + mock_format_ipv6_addr.side_effect = lambda x: x + self.relation_ids.return_value = ['foo'] + self.related_units.return_value = ['bar', 'baz'] + self.relation_get.side_effect = self.test_relation.get + self.test_relation.set({'service_host': 'foo', 'service_port': 5000, + 'internal_host': 'bar', 'internal_port': 5001, + 'region': 'regionOne regionTwo'}) + self.test_config.set('extra-regions', '{{{{') + self.maxDiff = None + self.context_complete.return_value = True + self.assertEqual( + with_regions_sorted(horizon_contexts.IdentityServiceContext()), + {'service_host': 'foo', 'service_port': 5000, + 'service_protocol': 'http', 'api_version': '2', + 'internal_host': 'bar', 'internal_port': 5001, + 'internal_protocol': 'http', + 'ks_host': 'foo', 'ks_port': 5000, + 'ks_protocol': 'http', + 'ks_endpoint_path': 'v2.0', + 'default_role': 'member', + 'regions': [{'endpoint': 'http://foo:5000/v2.0', + 'title': 'regionOne'}, + {'endpoint': 'http://foo:5000/v2.0', + 'title': 'regionTwo'}]}) + @patch("hooks.horizon_contexts.format_ipv6_addr") def test_IdentityServiceContext_multi_region_v3(self, mock_format_ipv6_addr): diff --git a/unit_tests/test_horizon_hooks.py b/unit_tests/test_horizon_hooks.py index 9a4f1c98..1c8a7334 100644 --- a/unit_tests/test_horizon_hooks.py +++ b/unit_tests/test_horizon_hooks.py @@ -266,6 +266,7 @@ class TestHorizonHooks(CharmTestCase): 'action-managed-upgrade': False, 'webroot': '/horizon', 'site-name': 'local', + 'extra-regions': '{}', }[key] self.config.side_effect = config_side_effect _is_leader.return_value = True @@ -283,6 +284,88 @@ class TestHorizonHooks(CharmTestCase): self.open_port.assert_has_calls([call(80), call(443)]) self.assertTrue(_custom_theme.called) + @patch('hooks.horizon_contexts.config') + @patch('hooks.horizon_hooks.check_custom_theme') + @patch('hooks.horizon_hooks.keystone_joined') + @patch('hooks.horizon_hooks.is_leader') + @patch('os.environ.get') + def test_config_changed_invalid_extra_regions( + self, _environ_get, _is_leader, _joined, _custom_theme, _config + ): + self.relation_ids.side_effect = lambda _: [] + self.config.side_effect = _config.side_effect = lambda key: { + 'ssl_key': 'somekey', + 'enforce-ssl': True, + 'dns-ha': True, + 'os-public-hostname': 'dashboard.intranet.test', + 'prefer-ipv6': False, + 'action-managed-upgrade': False, + 'webroot': '/horizon', + 'site-name': 'local', + 'extra-regions': '{', + }[key] + _is_leader.return_value = True + _environ_get.return_value = '' + self.openstack_upgrade_available.return_value = False + self._call_hook('config-changed') + self.status_set.assert_has_calls([ + call("blocked", "Invalid 'extra-regions' config value") + ]) + + @patch('hooks.horizon_contexts.config') + @patch('hooks.horizon_hooks.check_custom_theme') + @patch('hooks.horizon_hooks.keystone_joined') + @patch('hooks.horizon_hooks.is_leader') + @patch('os.environ.get') + def test_config_changed_invalid_extra_regions2( + self, _environ_get, _is_leader, _joined, _custom_theme, _config + ): + self.relation_ids.side_effect = lambda _: [] + self.config.side_effect = _config.side_effect = lambda key: { + 'ssl_key': 'somekey', + 'enforce-ssl': True, + 'dns-ha': True, + 'os-public-hostname': 'dashboard.intranet.test', + 'prefer-ipv6': False, + 'action-managed-upgrade': False, + 'webroot': '/horizon', + 'site-name': 'local', + 'extra-regions': '{"test": 2}', + }[key] + _is_leader.return_value = True + _environ_get.return_value = '' + self.openstack_upgrade_available.return_value = False + self._call_hook('config-changed') + self.status_set.assert_has_calls([ + call("blocked", "Invalid 'extra-regions' config value") + ]) + + @patch('hooks.horizon_contexts.config') + @patch('hooks.horizon_hooks.check_custom_theme') + @patch('hooks.horizon_hooks.keystone_joined') + @patch('hooks.horizon_hooks.is_leader') + @patch('os.environ.get') + def test_config_changed_valid_extra_regions( + self, _environ_get, _is_leader, _joined, _custom_theme, _config + ): + self.relation_ids.side_effect = lambda _: [] + self.config.side_effect = _config.side_effect = lambda key: { + 'ssl_key': 'somekey', + 'enforce-ssl': True, + 'dns-ha': True, + 'os-public-hostname': 'dashboard.intranet.test', + 'prefer-ipv6': False, + 'action-managed-upgrade': False, + 'webroot': '/horizon', + 'site-name': 'local', + 'extra-regions': '{"test": "http://example.com/v3"}', + }[key] + _is_leader.return_value = True + _environ_get.return_value = '' + self.openstack_upgrade_available.return_value = False + self._call_hook('config-changed') + self.status_set.assert_not_called() + @patch('hooks.horizon_hooks.check_custom_theme') @patch('hooks.horizon_hooks.is_leader') def test_config_changed_do_upgrade(self, _is_leader, _custom_theme):