diff --git a/README.md b/README.md index edcb9118..39790d27 100644 --- a/README.md +++ b/README.md @@ -93,3 +93,22 @@ to also deploy the dashboard with load balancing proxy such as HAProxy: This option potentially provides better scale-out than using the charm in conjunction with the hacluster charm. + + +Custom Theme +============ +This charm supports providing a custom theme as documented in the [themes +configuration]. In order to enable this capability the configuration options +'ubuntu-theme' and 'default-theme' must both be turned off and the option +'custom-theme' turned on. + +Once the option is enabled a custom theme can be provided via a juju resource. +The resource should be a .tgz file with the contents of your custom theme. If +the file 'local_settings.py' is included it will be sourced. + + juju attach-resource openstack-dashboard theme=theme.tgz + +Repeating the attach-resource will update the theme and turning off the +custom-theme option will return to the default. + +[themes]: https://docs.openstack.org/horizon/latest/configuration/themes.html diff --git a/config.yaml b/config.yaml index e9d747ef..af9afc41 100644 --- a/config.yaml +++ b/config.yaml @@ -175,6 +175,13 @@ options: . NOTE: This setting is supported >= OpenStack Liberty and this setting is mutually exclusive to ubuntu-theme. + custom-theme: + type: boolean + default: False + description: | + Use a custom theme supplied as a resource. + NOTE: This setting is supported >= OpenStack Mitaka and + this setting is mutually exclustive to ubuntu-theme and default-theme. secret: type: string default: diff --git a/hooks/horizon_contexts.py b/hooks/horizon_contexts.py index 6b74a835..5e62afac 100644 --- a/hooks/horizon_contexts.py +++ b/hooks/horizon_contexts.py @@ -182,6 +182,7 @@ class HorizonContext(OSContextGenerator): "webroot": config('webroot') or '/', "ubuntu_theme": bool_from_string(config('ubuntu-theme')), "default_theme": config('default-theme'), + "custom_theme": config('custom-theme'), "secret": config('secret') or pwgen(), 'support_profile': config('profile') if config('profile') in ['cisco'] else None, @@ -210,7 +211,8 @@ class ApacheContext(OSContextGenerator): 'http_port': 70, 'https_port': 433, 'enforce_ssl': False, - 'hsts_max_age_seconds': config('hsts-max-age-seconds') + 'hsts_max_age_seconds': config('hsts-max-age-seconds'), + "custom_theme": config('custom-theme'), } if config('enforce-ssl'): diff --git a/hooks/horizon_hooks.py b/hooks/horizon_hooks.py index ba9978d7..254a38af 100755 --- a/hooks/horizon_hooks.py +++ b/hooks/horizon_hooks.py @@ -64,6 +64,7 @@ from horizon_utils import ( restart_on_change, assess_status, db_migration, + check_custom_theme, ) from charmhelpers.contrib.network.ip import ( get_iface_for_address, @@ -110,6 +111,7 @@ def upgrade_charm(): apt_install(filter_installed_packages(determine_packages()), fatal=True) update_nrpe_config() CONFIGS.write_all() + check_custom_theme() @hooks.hook('config-changed') @@ -150,6 +152,7 @@ def config_changed(): save_script_rc(**env_vars) update_nrpe_config() CONFIGS.write_all() + check_custom_theme() open_port(80) open_port(443) diff --git a/hooks/horizon_utils.py b/hooks/horizon_utils.py index 6be0db38..6d15e8ac 100644 --- a/hooks/horizon_utils.py +++ b/hooks/horizon_utils.py @@ -17,6 +17,7 @@ import horizon_contexts import os import subprocess import time +import tarfile from collections import OrderedDict import charmhelpers.contrib.openstack.context as context @@ -36,7 +37,8 @@ from charmhelpers.contrib.openstack.utils import ( ) from charmhelpers.core.hookenv import ( config, - log + log, + resource_get, ) from charmhelpers.core.host import ( cmp_pkgrevno, @@ -86,6 +88,9 @@ ROUTER_SETTING = ('/usr/share/openstack-dashboard/openstack_dashboard/enabled/' KEYSTONEV3_POLICY = ('/usr/share/openstack-dashboard/openstack_dashboard/conf/' 'keystonev3_policy.json') TEMPLATES = 'templates' +CUSTOM_THEME_DIR = ("/usr/share/openstack-dashboard/openstack_dashboard/" + "themes/custom") +LOCAL_DIR = '/usr/share/openstack-dashboard/openstack_dashboard/local/' CONFIG_FILES = OrderedDict([ (LOCAL_SETTINGS, { @@ -414,3 +419,27 @@ def db_migration(): subcommand = 'syncdb' cmd = ['/usr/share/openstack-dashboard/manage.py', subcommand, '--noinput'] subprocess.check_call(cmd) + + +def check_custom_theme(): + if not config('custom-theme'): + log('No custom theme configured, exiting') + return + try: + os.mkdir(CUSTOM_THEME_DIR) + except OSError as e: + if e.errno is 17: + pass # already exists + theme_file = resource_get('theme') + log('Retreived resource: {}'.format(theme_file)) + if theme_file: + with tarfile.open(theme_file, 'r:gz') as in_file: + in_file.extractall(CUSTOM_THEME_DIR) + custom_settings = '{}/local_settings.py'.format(CUSTOM_THEME_DIR) + if os.path.isfile(custom_settings): + try: + os.symlink(custom_settings, LOCAL_DIR + 'custom_theme.py') + except OSError as e: + if e.errno is 17: + pass # already exists + log('Custom theme updated'.format(theme_file)) diff --git a/metadata.yaml b/metadata.yaml index 242aeb97..d6627f29 100644 --- a/metadata.yaml +++ b/metadata.yaml @@ -38,3 +38,8 @@ requires: peers: cluster: interface: openstack-dashboard-ha +resources: + theme: + type: file + filename: theme.tgz + description: "Custom dashboard theme" diff --git a/templates/mitaka/local_settings.py b/templates/mitaka/local_settings.py index ec930578..51523d1e 100644 --- a/templates/mitaka/local_settings.py +++ b/templates/mitaka/local_settings.py @@ -861,6 +861,13 @@ if '{{ default_theme }}' not in [el[0] for el in AVAILABLE_THEMES]: 'themes/{{ default_theme }}'), ] DEFAULT_THEME = '{{ default_theme }}' +{% elif custom_theme %} +AVAILABLE_THEMES = [] +try: + from custom_theme import * +except ImportError: + pass +AVAILABLE_THEMES += [ ('custom', 'custom', 'themes/custom') ] {% endif %} WEBROOT = '{{ webroot }}' diff --git a/templates/mitaka/openstack-dashboard.conf b/templates/mitaka/openstack-dashboard.conf new file mode 100644 index 00000000..97dbf23d --- /dev/null +++ b/templates/mitaka/openstack-dashboard.conf @@ -0,0 +1,14 @@ +WSGIScriptAlias {{ webroot }} /usr/share/openstack-dashboard/openstack_dashboard/wsgi/django.wsgi +WSGIDaemonProcess horizon user=horizon group=horizon processes={{ processes }} threads=10 +WSGIProcessGroup horizon +{% if custom_theme %} +Alias /static/custom /usr/share/openstack-dashboard/openstack_dashboard/themes/custom/static/ +Alias /static/themes/custom /usr/share/openstack-dashboard/openstack_dashboard/themes/custom/static/ +{% endif %} +Alias /static /usr/share/openstack-dashboard/openstack_dashboard/static/ +Alias /horizon/static /usr/share/openstack-dashboard/openstack_dashboard/static/ + + + Order allow,deny + Allow from all + diff --git a/templates/newton/local_settings.py b/templates/newton/local_settings.py index aea9c422..2b6235bf 100644 --- a/templates/newton/local_settings.py +++ b/templates/newton/local_settings.py @@ -900,6 +900,13 @@ if '{{ default_theme }}' not in [el[0] for el in AVAILABLE_THEMES]: 'themes/{{ default_theme }}'), ] DEFAULT_THEME = '{{ default_theme }}' +{% elif custom_theme %} +AVAILABLE_THEMES = [] +try: + from custom_theme import * +except ImportError: + pass +AVAILABLE_THEMES += [ ('custom', 'custom', 'themes/custom') ] {% endif %} WEBROOT = '{{ webroot }}' diff --git a/templates/newton/openstack-dashboard.conf b/templates/newton/openstack-dashboard.conf index 28a1dc03..d0748506 100644 --- a/templates/newton/openstack-dashboard.conf +++ b/templates/newton/openstack-dashboard.conf @@ -1,6 +1,9 @@ WSGIScriptAlias {{ webroot }} /usr/share/openstack-dashboard/openstack_dashboard/wsgi/django.wsgi WSGIDaemonProcess horizon user=horizon group=horizon processes={{ processes }} threads=10 WSGIProcessGroup horizon +{% if custom_theme %} +Alias /static/themes/custom /usr/share/openstack-dashboard/openstack_dashboard/themes/custom/static/ +{% endif %} Alias /static /usr/share/openstack-dashboard/openstack_dashboard/static/ Alias /horizon/static /usr/share/openstack-dashboard/openstack_dashboard/static/ diff --git a/templates/ocata/local_settings.py b/templates/ocata/local_settings.py index 3d43c562..7f452cb3 100644 --- a/templates/ocata/local_settings.py +++ b/templates/ocata/local_settings.py @@ -902,6 +902,13 @@ if '{{ default_theme }}' not in [el[0] for el in AVAILABLE_THEMES]: 'themes/{{ default_theme }}'), ] DEFAULT_THEME = '{{ default_theme }}' +{% elif custom_theme %} +AVAILABLE_THEMES = [] +try: + from custom_theme import * +except ImportError: + pass +AVAILABLE_THEMES += [ ('custom', 'custom', 'themes/custom') ] {% endif %} WEBROOT = '{{ webroot }}' diff --git a/templates/ocata/openstack-dashboard.conf b/templates/ocata/openstack-dashboard.conf index 99feddfb..4b0c3e23 100644 --- a/templates/ocata/openstack-dashboard.conf +++ b/templates/ocata/openstack-dashboard.conf @@ -1,6 +1,9 @@ WSGIScriptAlias {{ webroot }} /usr/share/openstack-dashboard/openstack_dashboard/wsgi/django.wsgi WSGIDaemonProcess horizon user=horizon group=horizon processes={{ processes }} threads=10 WSGIProcessGroup horizon +{% if custom_theme %} +Alias /static/themes/custom /usr/share/openstack-dashboard/openstack_dashboard/themes/custom/static/ +{% endif %} Alias /static /var/lib/openstack-dashboard/static/ Alias /horizon/static /var/lib/openstack-dashboard/static/ diff --git a/templates/pike/openstack-dashboard.conf b/templates/pike/openstack-dashboard.conf index 99feddfb..4b0c3e23 100644 --- a/templates/pike/openstack-dashboard.conf +++ b/templates/pike/openstack-dashboard.conf @@ -1,6 +1,9 @@ WSGIScriptAlias {{ webroot }} /usr/share/openstack-dashboard/openstack_dashboard/wsgi/django.wsgi WSGIDaemonProcess horizon user=horizon group=horizon processes={{ processes }} threads=10 WSGIProcessGroup horizon +{% if custom_theme %} +Alias /static/themes/custom /usr/share/openstack-dashboard/openstack_dashboard/themes/custom/static/ +{% endif %} Alias /static /var/lib/openstack-dashboard/static/ Alias /horizon/static /var/lib/openstack-dashboard/static/ diff --git a/tests/basic_deployment.py b/tests/basic_deployment.py index 78c8f0e5..9186adc6 100644 --- a/tests/basic_deployment.py +++ b/tests/basic_deployment.py @@ -230,7 +230,7 @@ class OpenstackDashboardBasicDeployment(OpenStackAmuletDeployment): # add retry logic to unwedge the gate. This issue # should be revisited and root caused properly when time # allows. - @retry_on_exception(1) + @retry_on_exception(2, base_delay=2) def do_request(): response = urllib2.urlopen('http://%s/horizon' % (dashboard_ip)) return response.read() diff --git a/unit_tests/test_horizon_contexts.py b/unit_tests/test_horizon_contexts.py index 8f5cea72..765b6958 100644 --- a/unit_tests/test_horizon_contexts.py +++ b/unit_tests/test_horizon_contexts.py @@ -64,7 +64,8 @@ class TestHorizonContexts(CharmTestCase): self.assertEqual(horizon_contexts.ApacheContext()(), {'http_port': 70, 'https_port': 433, 'enforce_ssl': False, - 'hsts_max_age_seconds': 0}) + 'hsts_max_age_seconds': 0, + 'custom_theme': False}) def test_Apachecontext_enforce_ssl(self): self.test_config.set('enforce-ssl', True) @@ -72,7 +73,8 @@ class TestHorizonContexts(CharmTestCase): self.assertEquals(horizon_contexts.ApacheContext()(), {'http_port': 70, 'https_port': 433, 'enforce_ssl': True, - 'hsts_max_age_seconds': 0}) + 'hsts_max_age_seconds': 0, + 'custom_theme': False}) def test_Apachecontext_enforce_ssl_no_cert(self): self.test_config.set('enforce-ssl', True) @@ -80,7 +82,8 @@ class TestHorizonContexts(CharmTestCase): self.assertEquals(horizon_contexts.ApacheContext()(), {'http_port': 70, 'https_port': 433, 'enforce_ssl': False, - 'hsts_max_age_seconds': 0}) + 'hsts_max_age_seconds': 0, + 'custom_theme': False}) def test_Apachecontext_hsts_max_age_seconds(self): self.test_config.set('enforce-ssl', True) @@ -89,7 +92,8 @@ class TestHorizonContexts(CharmTestCase): self.assertEquals(horizon_contexts.ApacheContext()(), {'http_port': 70, 'https_port': 433, 'enforce_ssl': True, - 'hsts_max_age_seconds': 15768000}) + 'hsts_max_age_seconds': 15768000, + 'custom_theme': False}) @patch.object(horizon_contexts, 'get_ca_cert', lambda: None) @patch('os.chmod') @@ -125,6 +129,7 @@ class TestHorizonContexts(CharmTestCase): 'default_role': 'Member', 'webroot': '/horizon', 'ubuntu_theme': True, 'default_theme': None, + 'custom_theme': False, 'secret': 'secret', 'support_profile': None, "neutron_network_dvr": False, @@ -150,6 +155,7 @@ class TestHorizonContexts(CharmTestCase): 'default_role': 'Member', 'webroot': '/horizon', 'ubuntu_theme': True, 'default_theme': None, + 'custom_theme': False, 'secret': 'secret', 'support_profile': None, "neutron_network_dvr": False, @@ -175,6 +181,7 @@ class TestHorizonContexts(CharmTestCase): 'default_role': 'Member', 'webroot': '/horizon', 'ubuntu_theme': True, 'default_theme': None, + 'custom_theme': False, 'secret': 'secret', 'support_profile': None, "neutron_network_dvr": False, @@ -200,6 +207,7 @@ class TestHorizonContexts(CharmTestCase): 'default_role': 'Member', 'webroot': '/horizon', 'ubuntu_theme': False, 'default_theme': None, + 'custom_theme': False, 'secret': 'secret', 'support_profile': None, "neutron_network_dvr": False, @@ -226,6 +234,7 @@ class TestHorizonContexts(CharmTestCase): 'default_role': 'Member', 'webroot': '/horizon', 'ubuntu_theme': False, 'default_theme': 'material', + 'custom_theme': False, 'secret': 'secret', 'support_profile': None, "neutron_network_dvr": False, @@ -255,6 +264,7 @@ class TestHorizonContexts(CharmTestCase): 'default_role': 'Member', 'webroot': '/horizon', 'ubuntu_theme': True, 'default_theme': None, + 'custom_theme': False, 'secret': 'secret', 'support_profile': None, "neutron_network_dvr": False, @@ -280,6 +290,7 @@ class TestHorizonContexts(CharmTestCase): 'default_role': 'foo', 'webroot': '/horizon', 'ubuntu_theme': True, 'default_theme': None, + 'custom_theme': False, 'secret': 'secret', 'support_profile': None, "neutron_network_dvr": False, @@ -305,6 +316,7 @@ class TestHorizonContexts(CharmTestCase): 'default_role': 'Member', 'webroot': '/', 'ubuntu_theme': True, 'default_theme': None, + 'custom_theme': False, 'secret': 'secret', 'support_profile': None, "neutron_network_dvr": False, @@ -335,6 +347,7 @@ class TestHorizonContexts(CharmTestCase): 'default_role': 'Member', 'webroot': '/horizon', 'ubuntu_theme': True, 'default_theme': None, + 'custom_theme': False, 'secret': 'secret', 'support_profile': None, "neutron_network_dvr": True, @@ -360,6 +373,7 @@ class TestHorizonContexts(CharmTestCase): 'default_role': 'Member', 'webroot': '/horizon', 'ubuntu_theme': True, 'default_theme': None, + 'custom_theme': False, 'secret': 'secret', 'support_profile': None, "neutron_network_dvr": False, @@ -385,6 +399,7 @@ class TestHorizonContexts(CharmTestCase): 'default_role': 'Member', 'webroot': '/horizon', 'ubuntu_theme': True, 'default_theme': None, + 'custom_theme': False, 'secret': 'secret', 'support_profile': None, "neutron_network_dvr": False, @@ -411,6 +426,7 @@ class TestHorizonContexts(CharmTestCase): 'default_role': 'Member', 'webroot': '/horizon', 'ubuntu_theme': True, 'default_theme': None, + 'custom_theme': False, 'secret': 'secret', 'support_profile': None, "neutron_network_dvr": False, @@ -437,6 +453,7 @@ class TestHorizonContexts(CharmTestCase): 'default_role': 'Member', 'webroot': '/horizon', 'ubuntu_theme': True, 'default_theme': None, + 'custom_theme': False, 'secret': 'secret', 'support_profile': None, "neutron_network_dvr": False, @@ -463,6 +480,7 @@ class TestHorizonContexts(CharmTestCase): 'default_role': 'Member', 'webroot': '/horizon', 'ubuntu_theme': True, 'default_theme': None, + 'custom_theme': False, 'secret': 'secret', 'support_profile': None, "neutron_network_dvr": False, diff --git a/unit_tests/test_horizon_hooks.py b/unit_tests/test_horizon_hooks.py index 4d0d3f0e..e7c20f24 100644 --- a/unit_tests/test_horizon_hooks.py +++ b/unit_tests/test_horizon_hooks.py @@ -132,11 +132,13 @@ class TestHorizonHooks(CharmTestCase): ) self.assertTrue(self.apt_install.called) + @patch('horizon_hooks.check_custom_theme') @patch.object(hooks, 'determine_packages') @patch.object(utils, 'path_hash') @patch.object(utils, 'service') def test_upgrade_charm_hook(self, _service, _hash, - _determine_packages): + _determine_packages, + _custom_theme): _determine_packages.return_value = [] side_effects = [] [side_effects.append(None) for f in RESTART_MAP.keys()] @@ -155,6 +157,7 @@ class TestHorizonHooks(CharmTestCase): call('start', 'haproxy'), ] self.assertEqual(ex, _service.call_args_list) + self.assertTrue(_custom_theme.called) def test_ha_joined_complete_config(self): conf = { @@ -258,8 +261,9 @@ class TestHorizonHooks(CharmTestCase): self.assertTrue(self.update_dns_ha_resource_params.called) self.relation_set.assert_called_with(**args) + @patch('horizon_hooks.check_custom_theme') @patch('horizon_hooks.keystone_joined') - def test_config_changed_no_upgrade(self, _joined): + def test_config_changed_no_upgrade(self, _joined, _custom_theme): def relation_ids_side_effect(rname): return { 'websso-trusted-dashboard': [ @@ -295,13 +299,16 @@ class TestHorizonHooks(CharmTestCase): self.assertTrue(self.save_script_rc.called) self.assertTrue(self.CONFIGS.write_all.called) self.open_port.assert_has_calls([call(80), call(443)]) + self.assertTrue(_custom_theme.called) - def test_config_changed_do_upgrade(self): + @patch('horizon_hooks.check_custom_theme') + def test_config_changed_do_upgrade(self, _custom_theme): self.relation_ids.return_value = [] self.test_config.set('openstack-origin', 'cloud:precise-grizzly') self.openstack_upgrade_available.return_value = True self._call_hook('config-changed') self.assertTrue(self.do_openstack_upgrade.called) + self.assertTrue(_custom_theme.called) def test_keystone_joined_in_relation(self): self._call_hook('identity-service-relation-joined')