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')