Full support for dashboard and panel configuration via service catalog.

There are no longer any dependencies on settings for whether or not
particular components are made available in the site.

Implements blueprint toggle-features.

Also fixes bug 929983, making the Horizon object a proper
singleton and ensuring test isolation for the base horizon tests.

Fixes a case where a missing service catalog would cause
a 500 error. Fixes bug 930833,

Change-Id: If19762afe75859e63aa7bd5128a6795655df2c90
This commit is contained in:
Gabriel Hurley 2012-02-09 22:29:23 -08:00
parent 797c497312
commit aed4766cc9
16 changed files with 254 additions and 112 deletions

View File

@ -36,5 +36,8 @@ from horizon.api.glance import *
from horizon.api.keystone import *
from horizon.api.nova import *
from horizon.api.swift import *
if settings.QUANTUM_ENABLED:
# Quantum is optional. Ignore it if it's not installed.
try:
from horizon.api.quantum import *
except ImportError:
pass

View File

@ -36,7 +36,8 @@ from django.utils.importlib import import_module
from django.utils.module_loading import module_has_submodule
from django.utils.translation import ugettext as _
from horizon.decorators import require_roles, _current_component
from horizon.decorators import (require_roles, require_services,
_current_component)
LOG = logging.getLogger(__name__)
@ -108,7 +109,7 @@ class Registry(object):
raise ValueError('Only classes may be registered.')
elif not issubclass(cls, self._registerable_class):
raise ValueError('Only %s classes or subclasses may be registered.'
% self._registerable_class)
% self._registerable_class.__name__)
if cls not in self._registry:
cls._registered_with = self
@ -135,9 +136,9 @@ class Registry(object):
def _registered(self, cls):
if inspect.isclass(cls) and issubclass(cls, self._registerable_class):
cls = self._registry.get(cls, None)
if cls:
return cls
found = self._registry.get(cls, None)
if found:
return found
else:
# Allow for fetching by slugs as well.
for registered in self._registry.values():
@ -153,9 +154,10 @@ class Registry(object):
"parent": parent,
"name": self.name})
else:
slug = getattr(cls, "slug", cls)
raise NotRegistered('%(type)s with slug "%(slug)s" is not '
'registered.'
% {"type": class_name, "slug": cls})
'registered.' % {"type": class_name,
"slug": slug})
class Panel(HorizonComponent):
@ -183,6 +185,11 @@ class Panel(HorizonComponent):
is combined cumulatively with any roles required on the
``Dashboard`` class with which it is registered.
.. attribute:: services
A list of service names, all of which must be in the service catalog
in order for this panel to be available.
.. attribute:: urls
Path to a URLconf of views for this panel using dotted Python
@ -235,7 +242,9 @@ class Panel(HorizonComponent):
# Apply access controls to all views in the patterns
roles = getattr(self, 'roles', [])
services = getattr(self, 'services', [])
_decorate_urlconf(urlpatterns, require_roles, roles)
_decorate_urlconf(urlpatterns, require_services, services)
_decorate_urlconf(urlpatterns, _current_component, panel=self)
# Return the three arguments to django.conf.urls.defaults.include
@ -295,13 +304,18 @@ class Dashboard(Registry, HorizonComponent):
for this dashboard, that's the panel that is displayed.
Default: ``None``.
.. attribute: roles
.. attribute:: roles
A list of role names, all of which a user must possess in order
to access any panel registered with this dashboard. This attribute
is combined cumulatively with any roles required on individual
:class:`~horizon.Panel` classes.
.. attribute:: services
A list of service names, all of which must be in the service catalog
in order for this dashboard to be available.
.. attribute:: urls
Optional path to a URLconf of additional views for this dashboard
@ -410,7 +424,9 @@ class Dashboard(Registry, HorizonComponent):
_decorate_urlconf(urlpatterns, login_required)
# Apply access controls to all views in the patterns
roles = getattr(self, 'roles', [])
services = getattr(self, 'services', [])
_decorate_urlconf(urlpatterns, require_roles, roles)
_decorate_urlconf(urlpatterns, require_services, services)
_decorate_urlconf(urlpatterns, _current_component, dashboard=self)
# Return the three arguments to django.conf.urls.defaults.include
@ -437,13 +453,11 @@ class Dashboard(Registry, HorizonComponent):
@classmethod
def register(cls, panel):
""" Registers a :class:`~horizon.Panel` with this dashboard. """
from horizon import Horizon
return Horizon.register_panel(cls, panel)
@classmethod
def unregister(cls, panel):
""" Unregisters a :class:`~horizon.Panel` from this dashboard. """
from horizon import Horizon
return Horizon.unregister_panel(cls, panel)
@ -465,7 +479,8 @@ class LazyURLPattern(SimpleLazyObject):
class Site(Registry, HorizonComponent):
""" The core OpenStack Dashboard class. """
""" The overarching class which encompasses all dashboards and panels. """
# Required for registry
_registerable_class = Dashboard
@ -620,9 +635,7 @@ class Site(Registry, HorizonComponent):
def _urls(self):
""" Constructs the URLconf for Horizon from registered Dashboards. """
urlpatterns = self._get_default_urlpatterns()
self._autodiscover()
# Add in each dashboard's views.
for dash in self._registry.values():
urlpatterns += patterns('',
@ -653,5 +666,19 @@ class Site(Registry, HorizonComponent):
if module_has_submodule(mod, mod_name):
raise
class HorizonSite(Site):
"""
A singleton implementation of Site such that all dealings with horizon
get the same instance no matter what. There can be only one.
"""
_instance = None
def __new__(cls, *args, **kwargs):
if not cls._instance:
cls._instance = super(Site, cls).__new__(cls, *args, **kwargs)
return cls._instance
# The one true Horizon
Horizon = Site()
Horizon = HorizonSite()

View File

@ -34,17 +34,15 @@ LOG = logging.getLogger(__name__)
def horizon(request):
""" The main Horizon context processor. Required for Horizon to function.
Adds three variables to the request context:
The following variables are added to the request context:
``authorized_tenants``
A list of tenant objects which the current user has access to.
``object_store_configured``
Boolean. Will be ``True`` if there is a service of type
``object-store`` in the user's ``ServiceCatalog``.
``regions``
``network_configured``
Boolean. Will be ``True`` if ``settings.QUANTUM_ENABLED`` is ``True``.
A dictionary containing information about region support, the current
region, and available regions.
Additionally, it sets the names ``True`` and ``False`` in the context
to their boolean equivalents for convenience.
@ -63,17 +61,6 @@ def horizon(request):
if request.user.is_authenticated():
context['authorized_tenants'] = request.user.authorized_tenants
# Object Store/Swift context
catalog = getattr(request.user, 'service_catalog', [])
object_store = catalog and api.get_service_from_catalog(catalog,
'object-store')
context['object_store_configured'] = object_store
# Quantum context
# TODO(gabriel): Convert to service catalog check when Quantum starts
# supporting keystone integration.
context['network_configured'] = getattr(settings, 'QUANTUM_ENABLED', None)
# Region context/support
available_regions = getattr(settings, 'AVAILABLE_REGIONS', [])
regions = {'support': len(available_regions) > 1,

View File

@ -27,9 +27,6 @@ from horizon.dashboards.nova import dashboard
class Containers(horizon.Panel):
name = _("Containers")
slug = 'containers'
def nav(self, context):
return context['object_store_configured']
services = ('object-store',)
dashboard.Nova.register(Containers)

View File

@ -25,9 +25,7 @@ from horizon.dashboards.nova import dashboard
class Networks(horizon.Panel):
name = "Networks"
slug = 'networks'
def nav(self, context):
return context.get('network_configured', False)
services = ("network",)
dashboard.Nova.register(Networks)

View File

@ -25,7 +25,7 @@ import functools
from django.utils.decorators import available_attrs
from horizon.exceptions import NotAuthorized
from horizon.exceptions import NotAuthorized, NotFound
def _current_component(view_func, dashboard=None, panel=None):
@ -79,6 +79,46 @@ def require_roles(view_func, required):
return view_func
def require_services(view_func, required):
""" Enforces service-based access controls.
:param list required: A tuple of service type names, all of which the
must be present in the service catalog in order
access the decorated view.
Example usage::
from horizon.decorators import require_services
@require_services(['object-store'])
def my_swift_view(request):
...
Raises a :exc:`~horizon.exceptions.NotFound` exception if the
requirements are not met.
"""
# We only need to check each service once for a view, so we'll use a set
current_services = getattr(view_func, '_required_services', set([]))
view_func._required_services = current_services | set(required)
@functools.wraps(view_func, assigned=available_attrs(view_func))
def dec(request, *args, **kwargs):
if request.user.is_authenticated():
services = set([service['type'] for service in
request.user.service_catalog])
# set operator <= tests that all members of set 1 are in set 2
if view_func._required_services <= set(services):
return view_func(request, *args, **kwargs)
raise NotFound("The services for this view are not available.")
# If we don't have any services, just return the original view.
if required:
return dec
else:
return view_func
def enforce_admin_access(view_func):
""" Marks a view as requiring the ``"admin"`` role for access. """
return require_roles(view_func, ('admin',))

View File

@ -23,6 +23,7 @@ Middleware provided and used by Horizon.
import logging
from django import http
from django import shortcuts
from django.contrib import messages
from django.utils.translation import ugettext as _
@ -56,7 +57,7 @@ class HorizonMiddleware(object):
authd = api.tenant_list_for_token(request,
token,
endpoint_type='internalURL')
except Exception, e:
except:
authd = []
LOG.exception('Could not retrieve tenant list.')
if hasattr(request.user, 'message_set'):
@ -65,11 +66,18 @@ class HorizonMiddleware(object):
request.user.authorized_tenants = authd
def process_exception(self, request, exception):
""" Catch NotAuthorized and Http302 and handle them gracefully. """
"""
Catches internal Horizon exception classes such as NotAuthorized,
NotFound and Http302 and handles them gracefully.
"""
if isinstance(exception, exceptions.NotAuthorized):
messages.error(request, unicode(exception))
return shortcuts.redirect('/auth/login')
# If an internal "NotFound" error gets this far, return a real 404.
if isinstance(exception, exceptions.NotFound):
raise http.Http404(exception)
if isinstance(exception, exceptions.Http302):
if exception.message:
messages.error(request, exception.message)

View File

@ -1,14 +1,16 @@
{% load horizon %}
{% for heading, panels in components.iteritems %}
<h4>{{ heading }}</h4>
<ul class="main_nav">
{% for panel in panels %}
{% if user|can_haz:panel %}
<li>
{% with panels|can_haz_list:user as filtered_panels %}
{% if filtered_panels %}
<h4>{{ heading }}</h4>
<ul class="main_nav">
{% for panel in filtered_panels %}
<li>
<a href="{{ panel.get_absolute_url }}" {% if current == panel.slug %}class="active"{% endif %} tabindex='1'>{{ panel.name }}</a>
</li>
</li>
{% endfor %}
</ul>
{% endif %}
{% endfor %}
</ul>
{% endwith %}
{% endfor %}

View File

@ -28,16 +28,32 @@ register = template.Library()
@register.filter
def can_haz(user, component):
""" Checks if the given user has the necessary roles for the component. """
"""
Checks if the given user meets the requirements for the component. This
includes both user roles and services in the service catalog.
"""
if hasattr(user, 'roles'):
user_roles = set([role['name'].lower() for role in user.roles])
else:
user_roles = set([])
if set(getattr(component, 'roles', [])) <= user_roles:
roles_statisfied = set(getattr(component, 'roles', [])) <= user_roles
if hasattr(user, 'roles'):
services = set([service['type'] for service in user.service_catalog])
else:
services = set([])
services_statisfied = set(getattr(component, 'services', [])) <= services
if roles_statisfied and services_statisfied:
return True
return False
@register.filter
def can_haz_list(components, user):
return [component for component in components if can_haz(user, component)]
@register.inclusion_tag('horizon/_nav_list.html', takes_context=True)
def horizon_main_nav(context):
""" Generates top-level dashboard navigation entries. """

View File

@ -61,9 +61,7 @@ class TestCase(django_test.TestCase):
TEST_CONTEXT = {'authorized_tenants': [{'enabled': True,
'name': 'aTenant',
'id': '1',
'description': "None"}],
'object_store_configured': False,
'network_configured': False}
'description': "None"}]}
TEST_SERVICE_CATALOG = [
{"endpoints": [{
@ -94,6 +92,13 @@ class TestCase(django_test.TestCase):
"publicURL": "http://cdn.admin-nets.local:5000/v2.0"}],
"type": "identity",
"name": "identity"},
{"endpoints": [{
"adminURL": "http://example.com:9696/quantum",
"region": "RegionOne",
"internalURL": "http://example.com:9696/quantum",
"publicURL": "http://example.com:9696/quantum"}],
"type": "network",
"name": "quantum"},
{"endpoints": [{
"adminURL": "http://swift/swiftapi/admin",
"region": "RegionOne",

View File

@ -18,38 +18,74 @@
# License for the specific language governing permissions and limitations
# under the License.
import copy
from django.core.urlresolvers import NoReverseMatch
from django.conf import settings
from django.core import urlresolvers
from django.test.client import Client
from django.utils.importlib import import_module
import horizon
from horizon import base
from horizon import exceptions
from horizon import test
from horizon import users
from horizon.base import Horizon
class MyDash(horizon.Dashboard):
name = "My Dashboard"
slug = "mydash"
default_panel = "myslug"
class MyPanel(horizon.Panel):
name = "My Panel"
slug = "myslug"
services = ("compute",)
urls = 'horizon.tests.test_panel_urls'
class HorizonTests(test.TestCase):
class BaseHorizonTests(test.TestCase):
def setUp(self):
super(HorizonTests, self).setUp()
self._orig_horizon = copy.deepcopy(base.Horizon)
super(BaseHorizonTests, self).setUp()
# Trigger discovery, registration, and URLconf generation if it
# hasn't happened yet.
base.Horizon._urls()
# Store our original dashboards
self._discovered_dashboards = base.Horizon._registry.keys()
# Gather up and store our original panels for each dashboard
self._discovered_panels = {}
for dash in self._discovered_dashboards:
panels = base.Horizon._registry[dash]._registry.keys()
self._discovered_panels[dash] = panels
def tearDown(self):
super(HorizonTests, self).tearDown()
base.Horizon = self._orig_horizon
super(BaseHorizonTests, self).tearDown()
# Destroy our singleton and re-create it.
base.HorizonSite._instance = None
del base.Horizon
base.Horizon = base.HorizonSite()
# Reload the convenience references to Horizon stored in __init__
reload(import_module("horizon"))
# Re-register our original dashboards and panels.
# This is necessary because autodiscovery only works on the first
# import, and calling reload introduces innumerable additional
# problems. Manual re-registration is the only good way for testing.
for dash in self._discovered_dashboards:
base.Horizon.register(dash)
for panel in self._discovered_panels[dash]:
dash.register(panel)
def _reload_urls(self):
'''
Clears out the URL caches, reloads the root urls module, and
re-triggers the autodiscovery mechanism for Horizon. Allows URLs
to be re-calculated after registering new dashboards. Useful
only for testing and should never be used on a live site.
'''
urlresolvers.clear_url_caches()
reload(import_module(settings.ROOT_URLCONF))
base.Horizon._urls()
class HorizonTests(BaseHorizonTests):
def test_registry(self):
""" Verify registration and autodiscovery work correctly.
@ -57,11 +93,10 @@ class HorizonTests(test.TestCase):
by virtue of the fact that the dashboards listed in
``settings.INSTALLED_APPS`` are loaded from the start.
"""
# Registration
self.assertEqual(len(Horizon._registry), 3)
self.assertEqual(len(base.Horizon._registry), 3)
horizon.register(MyDash)
self.assertEqual(len(Horizon._registry), 4)
self.assertEqual(len(base.Horizon._registry), 4)
with self.assertRaises(ValueError):
horizon.register(MyPanel)
with self.assertRaises(ValueError):
@ -81,23 +116,24 @@ class HorizonTests(test.TestCase):
'<Dashboard: My Dashboard>'])
# Removal
self.assertEqual(len(Horizon._registry), 4)
self.assertEqual(len(base.Horizon._registry), 4)
horizon.unregister(MyDash)
self.assertEqual(len(Horizon._registry), 3)
self.assertEqual(len(base.Horizon._registry), 3)
with self.assertRaises(base.NotRegistered):
horizon.get_dashboard(MyDash)
def test_site(self):
self.assertEqual(unicode(Horizon), "Horizon")
self.assertEqual(repr(Horizon), "<Site: Horizon>")
dash = Horizon.get_dashboard('nova')
self.assertEqual(Horizon.get_default_dashboard(), dash)
self.assertEqual(unicode(base.Horizon), "Horizon")
self.assertEqual(repr(base.Horizon), "<Site: Horizon>")
dash = base.Horizon.get_dashboard('nova')
self.assertEqual(base.Horizon.get_default_dashboard(), dash)
user = users.User()
self.assertEqual(Horizon.get_user_home(user), dash.get_absolute_url())
self.assertEqual(base.Horizon.get_user_home(user),
dash.get_absolute_url())
def test_dashboard(self):
syspanel = horizon.get_dashboard("syspanel")
self.assertEqual(syspanel._registered_with, Horizon)
self.assertEqual(syspanel._registered_with, base.Horizon)
self.assertQuerysetEqual(syspanel.get_panels()['System Panel'],
['<Panel: Overview>',
'<Panel: Instances>',
@ -133,7 +169,7 @@ class HorizonTests(test.TestCase):
syspanel = horizon.get_dashboard("syspanel")
instances = syspanel.get_panel("instances")
instances.index_url_name = "does_not_exist"
with self.assertRaises(NoReverseMatch):
with self.assertRaises(urlresolvers.NoReverseMatch):
instances.get_absolute_url()
instances.index_url_name = "index"
self.assertEqual(instances.get_absolute_url(), "/syspanel/instances/")
@ -145,18 +181,50 @@ class HorizonTests(test.TestCase):
iter(urlpatterns)
reversed(urlpatterns)
def test_horizon_test_isolation_1(self):
""" Isolation Test Part 1: sets a value. """
syspanel = horizon.get_dashboard("syspanel")
syspanel.evil = True
class HorizonBaseViewTests(test.BaseViewTests):
def setUp(self):
super(HorizonBaseViewTests, self).setUp()
users.get_user_from_request = self._real_get_user_from_request
def test_horizon_test_isolation_2(self):
""" Isolation Test Part 2: The value set in part 1 should be gone. """
syspanel = horizon.get_dashboard("syspanel")
self.assertFalse(hasattr(syspanel, "evil"))
class HorizonBaseViewTests(BaseHorizonTests, test.BaseViewTests):
def test_public(self):
users.get_user_from_request = self._real_get_user_from_request
settings = horizon.get_dashboard("settings")
# Known to have no restrictions on it other than being logged in.
user_panel = settings.get_panel("user")
url = user_panel.get_absolute_url()
client = Client() # Get a clean, logged out client instance.
# Get a clean, logged out client instance.
client = Client()
client.logout()
resp = client.get(url)
self.assertRedirectsNoFollow(resp, '/accounts/login/?next=/settings/')
def test_required_services(self):
horizon.register(MyDash)
MyDash.register(MyPanel)
dash = horizon.get_dashboard("mydash")
panel = dash.get_panel('myslug')
self._reload_urls()
# With the required service, the page returns fine.
resp = self.client.get(panel.get_absolute_url())
self.assertEqual(resp.status_code, 200)
# Remove the required service from the service catalog and we
# should get a 404.
new_catalog = [service for service in self.request.user.service_catalog
if service['type'] != MyPanel.services[0]]
tenants = self.TEST_CONTEXT['authorized_tenants']
self.setActiveUser(token=self.TEST_TOKEN,
username=self.TEST_USER,
tenant_id=self.TEST_TENANT,
service_catalog=new_catalog,
authorized_tenants=tenants)
resp = self.client.get(panel.get_absolute_url())
self.assertEqual(resp.status_code, 404)

View File

@ -52,15 +52,3 @@ class ContextProcessorTests(test.TestCase):
self.assertEqual(len(context['authorized_tenants']), 1)
tenant = context['authorized_tenants'].pop()
self.assertEqual(tenant['id'], self.TEST_TENANT)
def test_object_store(self):
# Returns the object store service data when it's in the catalog
context = context_processors.horizon(self.request)
self.assertNotEqual(None, context['object_store_configured'])
# Returns None when the object store is not in the catalog
new_catalog = [service for service in self.request.user.service_catalog
if service['type'] != 'object-store']
self.request.user.service_catalog = new_catalog
context = context_processors.horizon(self.request)
self.assertEqual(None, context['object_store_configured'])

View File

View File

@ -0,0 +1,21 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4
# Copyright 2011 Nebula, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
from django.conf.urls.defaults import *
urlpatterns = patterns('',
url(r'^$', 'horizon.tests.views.fakeView', name='index'),
)

View File

@ -71,12 +71,6 @@ NOVA_DEFAULT_REGION = 'test'
NOVA_ACCESS_KEY = 'test'
NOVA_SECRET_KEY = 'test'
QUANTUM_URL = '127.0.0.1'
QUANTUM_PORT = '9696'
QUANTUM_TENANT = '1234'
QUANTUM_CLIENT_VERSION = '0.1'
QUANTUM_ENABLED = True
CREDENTIAL_AUTHORIZATION_DAYS = 2
CREDENTIAL_DOWNLOAD_URL = TESTSERVER + '/credentials/'
@ -95,11 +89,6 @@ HORIZON_CONFIG = {
'default_dashboard': 'nova',
}
SWIFT_ACCOUNT = 'test'
SWIFT_USER = 'tester'
SWIFT_PASS = 'testing'
SWIFT_AUTHURL = 'http://swift/swiftapi/v1.0'
AVAILABLE_REGIONS = [
('http://localhost:5000/v2.0', 'local'),
('http://remote:5000/v2.0', 'remote'),

View File

@ -55,13 +55,6 @@ OPENSTACK_KEYSTONE_DEFAULT_ROLE = "Member"
# providing a paging element (a "more" link) to paginate results.
API_RESULT_LIMIT = 1000
# Configure quantum connection details for networking
QUANTUM_ENABLED = True
QUANTUM_URL = '%s' % OPENSTACK_HOST
QUANTUM_PORT = '9696'
QUANTUM_TENANT = '1234'
QUANTUM_CLIENT_VERSION='0.1'
# If you have external monitoring links, eg:
# EXTERNAL_MONITORING = [
# ['Nagios','http://foo.com'],