Implementation of a Heat stacks UI.

This change implements a UI to launch, manage and delete Heat stacks.

The launch screens are implemented with a set of chained forms where
the second form is dynamically built from the template in the first step.

A significant portion of this change was derived from
the work Dan Radez <dradez@redhat.com> did on thermal:
https://github.com/steveb/heat-horizon

UX flow revisions and basic test cases by Gabriel Hurley.

Implements blueprint: heat-ui

Change-Id: I294e93bed6da9dd3553e8b4a6a1c09b7c165a555
This commit is contained in:
Steve Baker 2013-03-18 16:21:49 +13:00 committed by Gabriel Hurley
parent 5212992100
commit 1f71152b4b
26 changed files with 1537 additions and 7 deletions

View File

@ -100,6 +100,8 @@ class ModalFormView(ModalFormMixin, generic.FormView):
self.get_object_display(handled)]
response = http.HttpResponse(json.dumps(data))
response["X-Horizon-Add-To-Field"] = field_id
elif isinstance(handled, http.HttpResponse):
return handled
else:
success_url = self.get_success_url()
response = http.HttpResponseRedirect(success_url)

View File

@ -113,8 +113,8 @@ class ContainerView(browsers.ResourceBrowserView):
context['container_name'] = self.kwargs["container_name"]
context['subfolders'] = []
if self.kwargs["subfolder_path"]:
(parent, slash, folder) = self.kwargs["subfolder_path"].\
strip('/').rpartition('/')
(parent, slash, folder) = self.kwargs["subfolder_path"] \
.strip('/').rpartition('/')
while folder:
path = "%s%s%s/" % (parent, slash, folder)
context['subfolders'].insert(0, (folder, path))

View File

@ -44,10 +44,17 @@ class ObjectStorePanels(horizon.PanelGroup):
panels = ('containers',)
class OrchestrationPanels(horizon.PanelGroup):
name = _("Orchestration")
slug = "orchestration"
panels = ('stacks',)
class Project(horizon.Dashboard):
name = _("Project")
slug = "project"
panels = (BasePanels, NetworkPanels, ObjectStorePanels)
panels = (
BasePanels, NetworkPanels, ObjectStorePanels, OrchestrationPanels)
default_panel = 'overview'
supports_tenants = True

View File

@ -0,0 +1,248 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4
# 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.
import json
import logging
import re
from django.utils.translation import ugettext_lazy as _
from horizon import exceptions
from horizon import forms
from horizon import messages
from openstack_dashboard import api
LOG = logging.getLogger(__name__)
def exception_to_validation_msg(e):
'''
Extracts a validation message to display to the user.
This needs to be a pattern matching approach until the Heat
API returns exception data in a parsable format.
'''
validation_patterns = [
"Remote error: \w* {'Error': '(.*?)'}",
'Remote error: \w* (.*?) \[',
'400 Bad Request\n\nThe server could not comply with the request '
'since it is either malformed or otherwise incorrect.\n\n (.*)',
'(ParserError: .*)'
]
for pattern in validation_patterns:
match = re.search(pattern, str(e))
if match:
return match.group(1)
class TemplateForm(forms.SelfHandlingForm):
class Meta:
name = _('Select Template')
help_text = _('From here you can select a template to launch '
'a stack.')
template_source = forms.ChoiceField(label=_('Template Source'),
choices=[('url', _('URL')),
('file', _('File')),
('raw', _('Direct Input'))],
widget=forms.Select(attrs={
'class': 'switchable',
'data-slug': 'source'}))
template_upload = forms.FileField(
label=_('Template File'),
help_text=_('A local template to upload.'),
widget=forms.FileInput(attrs={'class': 'switched',
'data-switch-on': 'source',
'data-source-file': _('Template File')}),
required=False)
template_url = forms.URLField(
label=_('Template URL'),
help_text=_('An external (HTTP) URL to load the template from.'),
widget=forms.TextInput(attrs={'class': 'switched',
'data-switch-on': 'source',
'data-source-url': _('Template URL')}),
required=False)
template_data = forms.CharField(
label=_('Template Data'),
help_text=_('The raw contents of the template.'),
widget=forms.widgets.Textarea(attrs={
'class': 'switched',
'data-switch-on': 'source',
'data-source-raw': _('Template Data')}),
required=False)
def __init__(self, *args, **kwargs):
self.next_view = kwargs.pop('next_view')
super(TemplateForm, self).__init__(*args, **kwargs)
def clean(self):
cleaned = super(TemplateForm, self).clean()
template_url = cleaned.get('template_url')
template_data = cleaned.get('template_data')
files = self.request.FILES
has_upload = 'template_upload' in files
# Uploaded file handler
if has_upload and not template_url:
log_template_name = self.request.FILES['template_upload'].name
LOG.info('got upload %s' % log_template_name)
tpl = self.request.FILES['template_upload'].read()
if tpl.startswith('{'):
try:
json.loads(tpl)
except Exception as e:
msg = _('There was a problem parsing the template: %s') % e
raise forms.ValidationError(msg)
cleaned['template_data'] = tpl
# URL handler
elif template_url and (has_upload or template_data):
msg = _('Please specify a template using only one source method.')
raise forms.ValidationError(msg)
# Check for raw template input
elif not template_url and not template_data:
msg = _('You must specify a template via one of the '
'available sources.')
raise forms.ValidationError(msg)
# Validate the template and get back the params.
kwargs = {}
if cleaned['template_data']:
kwargs['template'] = cleaned['template_data']
else:
kwargs['template_url'] = cleaned['template_url']
try:
validated = api.heat.template_validate(self.request, **kwargs)
cleaned['template_validate'] = validated
except Exception as e:
msg = exception_to_validation_msg(e)
if not msg:
msg = _('An unknown problem occurred validating the template.')
LOG.exception(msg)
raise forms.ValidationError(msg)
return cleaned
def handle(self, request, data):
kwargs = {'parameters': data['template_validate'],
'template_data': data['template_data'],
'template_url': data['template_url']}
# NOTE (gabriel): This is a bit of a hack, essentially rewriting this
# request so that we can chain it as an input to the next view...
# but hey, it totally works.
request.method = 'GET'
return self.next_view.as_view()(request, **kwargs)
class StackCreateForm(forms.SelfHandlingForm):
param_prefix = '__param_'
class Meta:
name = _('Create Stack')
template_data = forms.CharField(
widget=forms.widgets.HiddenInput,
required=False)
template_url = forms.CharField(
widget=forms.widgets.HiddenInput,
required=False)
parameters = forms.CharField(
widget=forms.widgets.HiddenInput,
required=True)
stack_name = forms.CharField(
max_length='255',
label=_('Stack Name'),
help_text=_('Name of the stack to create.'),
required=True)
timeout_mins = forms.IntegerField(
initial=60,
label=_('Creation Timeout (minutes)'),
help_text=_('Stack creation timeout in minutes.'),
required=True)
enable_rollback = forms.BooleanField(
label=_('Rollback On Failure'),
help_text=_('Enable rollback on create/update failure.'),
required=False)
def __init__(self, *args, **kwargs):
parameters = kwargs.pop('parameters')
super(StackCreateForm, self).__init__(*args, **kwargs)
self._build_parameter_fields(parameters)
def _build_parameter_fields(self, template_validate):
self.help_text = template_validate['Description']
params = template_validate.get('Parameters', {})
for param_key, param in params.items():
field_key = self.param_prefix + param_key
field_args = {
'initial': param.get('Default', None),
'label': param_key,
'help_text': param.get('Description', ''),
'required': param.get('Default', None) is None
}
param_type = param.get('Type', None)
if 'AllowedValues' in param:
choices = map(lambda x: (x, x), param['AllowedValues'])
field_args['choices'] = choices
field = forms.ChoiceField(**field_args)
elif param_type in ('CommaDelimitedList', 'String'):
if 'MinLength' in param:
field_args['min_length'] = int(param['MinLength'])
field_args['required'] = param.get('MinLength', 0) > 0
if 'MaxLength' in param:
field_args['max_length'] = int(param['MaxLength'])
field = forms.CharField(**field_args)
elif param_type == 'Number':
if 'MinValue' in param:
field_args['min_value'] = int(param['MinValue'])
if 'MaxValue' in param:
field_args['max_value'] = int(param['MaxValue'])
field = forms.IntegerField(**field_args)
self.fields[field_key] = field
def handle(self, request, data):
prefix_length = len(self.param_prefix)
params_list = [(k[prefix_length:], v) for (k, v) in data.iteritems()
if k.startswith(self.param_prefix)]
fields = {
'stack_name': data.get('stack_name'),
'timeout_mins': data.get('timeout_mins'),
'disable_rollback': not(data.get('enable_rollback')),
'parameters': dict(params_list)
}
if data.get('template_data'):
fields['template'] = data.get('template_data')
else:
fields['template_url'] = data.get('template_url')
try:
api.heat.stack_create(self.request, **fields)
messages.success(request, _("Stack creation started."))
return True
except:
exceptions.handle(request, _('Stack creation failed.'))

View File

@ -0,0 +1,83 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4
# 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.
import json
import logging
import urlparse
from django.core.urlresolvers import reverse
from django.template.defaultfilters import register
from openstack_dashboard.api.swift import FOLDER_DELIMITER
LOG = logging.getLogger(__name__)
resource_urls = {
"AWS::EC2::Instance": {
'link': 'horizon:project:instances:detail'},
"AWS::EC2::NetworkInterface": {
'link': 'horizon:project:networks:ports:detail'},
"AWS::EC2::RouteTable": {
'link': 'horizon:project:routers:detail'},
"AWS::EC2::Subnet": {
'link': 'horizon:project:networks:subnets:detail'},
"AWS::EC2::Volume": {
'link': 'horizon:project:volumes:detail'},
"AWS::EC2::VPC": {
'link': 'horizon:project:networks:detail'},
"AWS::S3::Bucket": {
'link': 'horizon:project:containers:index'},
"OS::Quantum::Net": {
'link': 'horizon:project:networks:detail'},
"OS::Quantum::Port": {
'link': 'horizon:project:networks:ports:detail'},
"OS::Quantum::Router": {
'link': 'horizon:project:routers:detail'},
"OS::Quantum::Subnet": {
'link': 'horizon:project:networks:subnets:detail'},
"OS::Swift::Container": {
'link': 'horizon:project:containers:index',
'format_pattern': '%s' + FOLDER_DELIMITER},
}
def resource_to_url(resource):
if not resource or not resource.physical_resource_id:
return None
mapping = resource_urls.get(resource.resource_type, {})
try:
if 'link' not in mapping:
return None
format_pattern = mapping.get('format_pattern') or '%s'
rid = format_pattern % resource.physical_resource_id
url = reverse(mapping['link'], args=(rid,))
except Exception as e:
LOG.exception(e)
return None
return url
@register.filter
def stack_output(output):
if not output:
return u''
if isinstance(output, dict) or isinstance(output, list):
return u'<pre>%s</pre>' % json.dumps(output, indent=2)
if isinstance(output, basestring):
parts = urlparse.urlsplit(output)
if parts.netloc and parts.scheme in ('http', 'https'):
return u'<a href="%s" target="_blank">%s</a>' % (output, output)
return unicode(output)

View File

@ -0,0 +1,27 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4
# 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.utils.translation import ugettext_lazy as _
import horizon
from openstack_dashboard.dashboards.project import dashboard
class Stacks(horizon.Panel):
name = _("Stacks")
slug = "stacks"
permissions = ('openstack.services.orchestration',)
dashboard.Project.register(Stacks)

View File

@ -0,0 +1,179 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4
# 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.
import logging
from django.http import Http404
from django.template.defaultfilters import timesince
from django.template.defaultfilters import title
from django.utils.translation import ugettext_lazy as _
from horizon import messages
from horizon import tables
from horizon.utils.filters import parse_isotime
from horizon.utils.filters import replace_underscores
from heatclient import exc
from openstack_dashboard import api
from openstack_dashboard.dashboards.project.stacks import mappings
LOG = logging.getLogger(__name__)
class LaunchStack(tables.LinkAction):
name = "launch"
verbose_name = _("Launch Stack")
url = "horizon:project:stacks:select_template"
classes = ("btn-create", "ajax-modal")
class DeleteStack(tables.BatchAction):
name = "delete"
action_present = _("Delete")
action_past = _("Scheduled deletion of")
data_type_singular = _("Stack")
data_type_plural = _("Stacks")
classes = ('btn-danger', 'btn-terminate')
def action(self, request, stack_id):
api.heat.stack_delete(request, stack_id)
class StacksUpdateRow(tables.Row):
ajax = True
def get_data(self, request, stack_id):
try:
return api.heat.stack_get(request, stack_id)
except exc.HTTPNotFound:
# returning 404 to the ajax call removes the
# row from the table on the ui
raise Http404
except Exception as e:
messages.error(request, e)
class StacksTable(tables.DataTable):
STATUS_CHOICES = (
("Create Complete", True),
("Create Failed", False),
)
name = tables.Column("stack_name",
verbose_name=_("Stack Name"),
link="horizon:project:stacks:detail",)
created = tables.Column("creation_time",
verbose_name=_("Created"),
filters=(parse_isotime, timesince))
updated = tables.Column("updated_time",
verbose_name=_("Updated"),
filters=(parse_isotime, timesince))
status = tables.Column("stack_status",
filters=(title, replace_underscores),
verbose_name=_("Status"),
status=True,
status_choices=STATUS_CHOICES)
def get_object_display(self, stack):
return stack.stack_name
class Meta:
name = "stacks"
verbose_name = _("Stacks")
status_columns = ["status", ]
row_class = StacksUpdateRow
table_actions = (LaunchStack, DeleteStack,)
row_actions = (DeleteStack, )
class EventsTable(tables.DataTable):
logical_resource = tables.Column('logical_resource_id',
verbose_name=_("Stack Resource"),
link=lambda d: d.logical_resource_id,)
physical_resource = tables.Column('physical_resource_id',
verbose_name=_("Resource"),
link=mappings.resource_to_url)
timestamp = tables.Column('event_time',
verbose_name=_("Time Since Event"),
filters=(parse_isotime, timesince))
status = tables.Column("resource_status",
filters=(title, replace_underscores),
verbose_name=_("Status"),)
statusreason = tables.Column("resource_status_reason",
verbose_name=_("Status Reason"),)
class Meta:
name = "events"
verbose_name = _("Stack Events")
class ResourcesUpdateRow(tables.Row):
ajax = True
def get_data(self, request, resource_name):
try:
stack = self.table.stack
stack_identifier = '%s/%s' % (stack.stack_name, stack.id)
return api.heat.resource_get(
request, stack_identifier, resource_name)
except exc.HTTPNotFound:
# returning 404 to the ajax call removes the
# row from the table on the ui
raise Http404
except Exception as e:
messages.error(request, e)
class ResourcesTable(tables.DataTable):
STATUS_CHOICES = (
("Create Complete", True),
("Create Failed", False),
)
logical_resource = tables.Column('logical_resource_id',
verbose_name=_("Stack Resource"),
link=lambda d: d.logical_resource_id)
physical_resource = tables.Column('physical_resource_id',
verbose_name=_("Resource"),
link=mappings.resource_to_url)
resource_type = tables.Column("resource_type",
verbose_name=_("Stack Resource Type"),)
updated_time = tables.Column('updated_time',
verbose_name=_("Date Updated"),
filters=(parse_isotime, timesince))
status = tables.Column("resource_status",
filters=(title, replace_underscores),
verbose_name=_("Status"),
status=True,
status_choices=STATUS_CHOICES)
statusreason = tables.Column("resource_status_reason",
verbose_name=_("Status Reason"),)
def __init__(self, request, data=None,
needs_form_wrapper=None, **kwargs):
super(ResourcesTable, self).__init__(
request, data, needs_form_wrapper, **kwargs)
self.stack = kwargs['stack']
def get_object_id(self, datum):
return datum.logical_resource_id
class Meta:
name = "resources"
verbose_name = _("Stack Resources")
status_columns = ["status", ]
row_class = ResourcesUpdateRow

View File

@ -0,0 +1,100 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4
# 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.
import logging
from django.utils.translation import ugettext_lazy as _
from horizon import messages
from horizon import tabs
from openstack_dashboard import api
from .tables import EventsTable
from .tables import ResourcesTable
LOG = logging.getLogger(__name__)
class StackOverviewTab(tabs.Tab):
name = _("Overview")
slug = "overview"
template_name = "project/stacks/_detail_overview.html"
def get_context_data(self, request):
return {"stack": self.tab_group.kwargs['stack']}
class ResourceOverviewTab(tabs.Tab):
name = _("Overview")
slug = "resource_overview"
template_name = "project/stacks/_resource_overview.html"
def get_context_data(self, request):
return {
"resource": self.tab_group.kwargs['resource'],
"metadata": self.tab_group.kwargs['metadata']}
class StackEventsTab(tabs.Tab):
name = _("Events")
slug = "events"
template_name = "project/stacks/_detail_events.html"
preload = False
def get_context_data(self, request):
stack = self.tab_group.kwargs['stack']
try:
stack_identifier = '%s/%s' % (stack.stack_name, stack.id)
events = api.heat.events_list(self.request, stack_identifier)
LOG.debug('got events %s' % events)
except:
events = []
messages.error(request, _(
'Unable to get events for stack "%s".') % stack.stack_name)
return {"stack": stack,
"table": EventsTable(request, data=events), }
class StackResourcesTab(tabs.Tab):
name = _("Resources")
slug = "resources"
template_name = "project/stacks/_detail_resources.html"
preload = False
def get_context_data(self, request):
stack = self.tab_group.kwargs['stack']
try:
stack_identifier = '%s/%s' % (stack.stack_name, stack.id)
resources = api.heat.resources_list(self.request, stack_identifier)
LOG.debug('got resources %s' % resources)
except:
resources = []
messages.error(request, _(
'Unable to get resources for stack "%s".') % stack.stack_name)
return {"stack": stack,
"table": ResourcesTable(
request, data=resources, stack=stack), }
class StackDetailTabs(tabs.TabGroup):
slug = "stack_details"
tabs = (StackOverviewTab, StackResourcesTab, StackEventsTab)
sticky = True
class ResourceDetailTabs(tabs.TabGroup):
slug = "resource_details"
tabs = (ResourceOverviewTab,)
sticky = True

View File

@ -0,0 +1,26 @@
{% extends "horizon/common/_modal_form.html" %}
{% load i18n %}
{% load url from future %}
{% block form_id %}launch_stack{% endblock %}
{% block form_action %}{% url 'horizon:project:stacks:launch' %}{% endblock %}
{% block modal-header %}{% trans "Launch Stack" %}{% endblock %}
{% block modal_id %}launch_stack_modal{% endblock %}
{% block modal-body %}
<div class="left">
<fieldset>
{% include "horizon/common/_form_fields.html" %}
</fieldset>
</div>
<div class="right">
<h3>{% trans "Description" %}:</h3>
<p>{% trans "Create a new stack with the provided values." %}</p>
</div>
{% endblock %}
{% block modal-footer %}
<input class="btn btn-primary pull-right" type="submit" value="{% trans "Launch" %}" />
<a href="{% url 'horizon:project:stacks:index' %}" class="btn secondary cancel close">{% trans "Cancel" %}</a>
{% endblock %}

View File

@ -0,0 +1,3 @@
{% load i18n %}
{{ table.render }}

View File

@ -0,0 +1,66 @@
{% load i18n sizeformat %}
<h3>{% trans "Stack Overview" %}</h3>
<div class="info row-fluid detail">
<h4>{% trans "Info" %}</h4>
<hr class="header_rule">
<dl>
<dt>{% trans "Name" %}</dt>
<dd>{{ stack.stack_name }}</dd>
<dt>{% trans "ID" %}</dt>
<dd>{{ stack.id }}</dd>
<dt>{% trans "Description" %}</dt>
<dd>{{ stack.description }}</dd>
</dl>
</div>
<div class="status row-fluid detail">
<h4>{% trans "Status" %}</h4>
<hr class="header_rule">
<dl>
<dt>{% trans "Created" %}</dt>
<dd>{{ stack.creation_time|parse_isotime|timesince }}</dd>
<dt>{% trans "Last Updated" %}</dt>
<dd>{{ stack.updated_time|parse_isotime|timesince }}</dd>
<dt>{% trans "Status" %}</dt>
<dd>{{ stack.stack_status|title }}: {{ stack.stack_status_reason }}</dd>
</dl>
</div>
<div class="outputs row-fluid detail">
<h4>{% trans "Outputs" %}</h4>
<hr class="header_rule">
<dl>
{% for output in stack.outputs %}
<dt>{{ output.output_key }}</dt>
<dd>{{ output.description }}</dd>
<dd>
{% autoescape off %}
{{ output.output_value|stack_output }}
{% endautoescape %}</dd>
{% endfor %}
</dl>
</div>
<div class="parameters row-fluid detail">
<h4>{% trans "Stack Parameters" %}</h4>
<hr class="header_rule">
<dl>
{% for key, value in stack.parameters.items %}
<dt>{{ key }}</dt>
<dd>{{ value }}</dd>
{% endfor %}
</dl>
</div>
<div class="launch row-fluid detail">
<h4>{% trans "Launch Parameters" %}</h4>
<hr class="header_rule">
<dl>
<dt>{% trans "Timeout" %}</dt>
<dd>{{ stack.timeout_mins }} {% trans "Minutes" %}</dd>
<dt>{% trans "Rollback" %}</dt>
<dd>{% if stack.disable_rollback %}{% trans "Disabled" %}{% else %}{% trans "Enabled" %}{% endif %}</dd>
</dl>
</div>

View File

@ -0,0 +1,3 @@
{% load i18n %}
{{ table.render }}

View File

@ -0,0 +1,42 @@
{% load i18n sizeformat %}
<h3>{% trans "Resource Overview" %}</h3>
<div class="info row-fluid detail">
<h4>{% trans "Info" %}</h4>
<hr class="header_rule">
<dl>
<dt>{% trans "Stack Resource ID" %}</dt>
<dd>{{ resource.logical_resource_id }}</dd>
</dl>
<dl>
<dt>{% trans "Resource ID" %}</dt>
<dd>{{ resource.physical_resource_id }}</dd>
</dl>
<dl>
<dt>{% trans "Stack Resource Type" %}</dt>
<dd>{{ resource.resource_type }}</dd>
</dl>
<dl>
<dt>{% trans "Description" %}</dt>
<dd>{{ resource.description }}</dd>
</dl>
</div>
<div class="status row-fluid detail">
<h4>{% trans "Status" %}</h4>
<hr class="header_rule">
<dl>
<dt>{% trans "Last Updated" %}</dt>
<dd>{{ resource.updated_time|parse_isotime|timesince }}</dd>
<dt>{% trans "Status" %}</dt>
<dd>{{ resource.resource_status|title|replace_underscores }}: {{ resource.resource_status_reason }}</dd>
</dl>
</div>
<div class="status row-fluid detail">
<h4>{% trans "Resource Metadata" %}</h4>
<hr class="header_rule">
<pre>{{ metadata }}
</pre>
</div>

View File

@ -0,0 +1,27 @@
{% extends "horizon/common/_modal_form.html" %}
{% load i18n %}
{% load url from future %}
{% block form_id %}select_template{% endblock %}
{% block form_action %}{% url 'horizon:project:stacks:select_template' %}{% endblock %}
{% block form_attrs %}enctype="multipart/form-data"{% endblock %}
{% block modal-header %}{% trans "Select Template" %}{% endblock %}
{% block modal_id %}select_template_modal{% endblock %}
{% block modal-body %}
<div class="left">
<fieldset>
{% include "horizon/common/_form_fields.html" %}
</fieldset>
</div>
<div class="right">
<h3>{% trans "Description" %}:</h3>
<p>{% trans "Use one of the available template source options to specify the template to be used in creating this stack." %}</p>
</div>
{% endblock %}
{% block modal-footer %}
<input class="btn btn-primary pull-right" type="submit" value="{% trans "Next" %}" />
<a href="{% url 'horizon:project:stacks:index' %}" class="btn secondary cancel close">{% trans "Cancel" %}</a>
{% endblock %}

View File

@ -0,0 +1,11 @@
{% extends 'base.html' %}
{% load i18n %}
{% block title %}{% trans "Launch Stack" %}{% endblock %}
{% block page_header %}
{% include "horizon/common/_page_header.html" with title=_("Launch Stack") %}
{% endblock page_header %}
{% block main %}
{% include 'project/stacks/_create.html' %}
{% endblock %}

View File

@ -0,0 +1,15 @@
{% extends 'base.html' %}
{% load i18n sizeformat %}
{% block title %}{% trans "Stack Detail" %}{% endblock %}
{% block page_header %}
{% include "horizon/common/_page_header.html" with title=_("Stack Detail: ")|add:stack.stack_name %}
{% endblock page_header %}
{% block main %}
<div class="row-fluid">
<div class="span12">
{{ tab_group.render }}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,13 @@
{% extends 'base.html' %}
{% load i18n %}
{% block title %}{% trans "Stacks" %}{% endblock %}
{% block page_header %}
{% include "horizon/common/_page_header.html" with title=_("Stacks") %}
{% endblock page_header %}
{% block main %}
{{ table.render }}
{% endblock %}

View File

@ -0,0 +1,15 @@
{% extends 'base.html' %}
{% load i18n sizeformat %}
{% block title %}{% trans "Resource Detail" %}{% endblock %}
{% block page_header %}
{% include "horizon/common/_page_header.html" with title=_("Resource Detail: ")|add:resource.logical_resource_id %}
{% endblock page_header %}
{% block main %}
<div class="row-fluid">
<div class="span12">
{{ tab_group.render }}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,11 @@
{% extends 'base.html' %}
{% load i18n %}
{% block title %}{% trans "Select Template" %}{% endblock %}
{% block page_header %}
{% include "horizon/common/_page_header.html" with title=_("Select Template") %}
{% endblock page_header %}
{% block main %}
{% include 'project/stacks/_select_template.html' %}
{% endblock %}

View File

@ -0,0 +1,154 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4
# 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.
import json
from django.core.urlresolvers import reverse
from django import http
from mox import IsA
from openstack_dashboard import api
from openstack_dashboard.test import helpers as test
from . import forms
from . import mappings
INDEX_URL = reverse('horizon:project:stacks:index')
class MockResource(object):
def __init__(self, resource_type, physical_resource_id):
self.resource_type = resource_type
self.physical_resource_id = physical_resource_id
class MappingsTests(test.TestCase):
def test_mappings(self):
def assertMappingUrl(url, resource_type, physical_resource_id):
mock = MockResource(resource_type, physical_resource_id)
mock_url = mappings.resource_to_url(mock)
self.assertEqual(url, mock_url)
assertMappingUrl(
'/project/networks/subnets/aaa/detail',
'OS::Quantum::Subnet',
'aaa')
assertMappingUrl(
None,
'OS::Quantum::Subnet',
None)
assertMappingUrl(
None,
None,
None)
assertMappingUrl(
None,
'AWS::AutoScaling::LaunchConfiguration',
'aaa')
assertMappingUrl(
'/project/instances/aaa/',
'AWS::EC2::Instance',
'aaa')
assertMappingUrl(
'/project/containers/aaa/',
'OS::Swift::Container',
'aaa')
assertMappingUrl(
None,
'Foo::Bar::Baz',
'aaa')
def test_stack_output(self):
self.assertEqual(u'foo', mappings.stack_output('foo'))
self.assertEqual(u'', mappings.stack_output(None))
self.assertEqual(
u'<pre>[\n "one", \n "two", \n "three"\n]</pre>',
mappings.stack_output(['one', 'two', 'three']))
self.assertEqual(
u'<pre>{\n "foo": "bar"\n}</pre>',
mappings.stack_output({'foo': 'bar'}))
self.assertEqual(
u'<a href="http://www.example.com/foo" target="_blank">'
'http://www.example.com/foo</a>',
mappings.stack_output('http://www.example.com/foo'))
class StackTests(test.TestCase):
@test.create_stubs({api.heat: ('stacks_list',)})
def test_index(self):
stacks = self.stacks.list()
api.heat.stacks_list(IsA(http.HttpRequest)) \
.AndReturn(stacks)
self.mox.ReplayAll()
res = self.client.get(INDEX_URL)
self.assertTemplateUsed(res, 'project/stacks/index.html')
self.assertIn('table', res.context)
resp_stacks = res.context['table'].data
self.assertEqual(len(resp_stacks), len(stacks))
@test.create_stubs({api.heat: ('stack_create', 'template_validate')})
def test_launch_stack(self):
template = self.stack_templates.first()
stack = self.stacks.first()
api.heat.template_validate(IsA(http.HttpRequest),
template=template.data) \
.AndReturn(json.loads(template.validate))
api.heat.stack_create(IsA(http.HttpRequest),
stack_name=stack.stack_name,
timeout_mins=60,
disable_rollback=True,
template=template.data,
parameters=IsA(dict))
self.mox.ReplayAll()
url = reverse('horizon:project:stacks:select_template')
res = self.client.get(url)
self.assertTemplateUsed(res, 'project/stacks/select_template.html')
form_data = {'template_source': 'raw',
'template_data': template.data,
'method': forms.TemplateForm.__name__}
res = self.client.post(url, form_data)
self.assertTemplateUsed(res, 'project/stacks/create.html')
url = reverse('horizon:project:stacks:launch')
form_data = {'template_source': 'raw',
'template_data': template.data,
'parameters': template.validate,
'stack_name': stack.stack_name,
"timeout_mins": 60,
"disable_rollback": True,
"__param_DBUsername": "admin",
"__param_LinuxDistribution": "F17",
"__param_InstanceType": "m1.small",
"__param_KeyName": "test",
"__param_DBPassword": "admin",
"__param_DBRootPassword": "admin",
"__param_DBName": "wordpress",
'method': forms.StackCreateForm.__name__}
res = self.client.post(url, form_data)
self.assertRedirectsNoFollow(res, INDEX_URL)

View File

@ -0,0 +1,34 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4
# 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 patterns
from django.conf.urls.defaults import url
from .views import CreateStackView
from .views import DetailView
from .views import IndexView
from .views import ResourceView
from .views import SelectTemplateView
urlpatterns = patterns(
'',
url(r'^$', IndexView.as_view(), name='index'),
url(r'^select_template$',
SelectTemplateView.as_view(),
name='select_template'),
url(r'^launch$', CreateStackView.as_view(), name='launch'),
url(r'^stack/(?P<stack_id>[^/]+)/$', DetailView.as_view(), name='detail'),
url(r'^stack/(?P<stack_id>[^/]+)/(?P<resource_name>[^/]+)/$',
ResourceView.as_view(), name='resource'),
)

View File

@ -0,0 +1,157 @@
# vim: tabstop=4 shiftwidth=4 softtabstop=4
# 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.
import json
import logging
from horizon import exceptions
from horizon import forms
from horizon import tables
from horizon import tabs
from django.core.urlresolvers import reverse
from django.core.urlresolvers import reverse_lazy
from django.utils.translation import ugettext_lazy as _
from openstack_dashboard import api
from .forms import StackCreateForm
from .forms import TemplateForm
from .tables import StacksTable
from .tabs import ResourceDetailTabs
from .tabs import StackDetailTabs
LOG = logging.getLogger(__name__)
class IndexView(tables.DataTableView):
table_class = StacksTable
template_name = 'project/stacks/index.html'
def get_data(self):
request = self.request
try:
stacks = api.heat.stacks_list(self.request)
except:
exceptions.handle(request, _('Unable to retrieve stack list.'))
stacks = []
return stacks
class SelectTemplateView(forms.ModalFormView):
form_class = TemplateForm
template_name = 'project/stacks/select_template.html'
success_url = reverse_lazy('horizon:project:stacks:launch')
def get_form_kwargs(self):
kwargs = super(SelectTemplateView, self).get_form_kwargs()
kwargs['next_view'] = CreateStackView
return kwargs
class CreateStackView(forms.ModalFormView):
form_class = StackCreateForm
template_name = 'project/stacks/create.html'
success_url = reverse_lazy('horizon:project:stacks:index')
def get_initial(self):
initial = {}
if 'template_data' in self.kwargs:
initial['template_data'] = self.kwargs['template_data']
if 'template_url' in self.kwargs:
initial['template_url'] = self.kwargs['template_url']
if 'parameters' in self.kwargs:
initial['parameters'] = json.dumps(self.kwargs['parameters'])
return initial
def get_form_kwargs(self):
kwargs = super(CreateStackView, self).get_form_kwargs()
if 'parameters' in self.kwargs:
kwargs['parameters'] = self.kwargs['parameters']
else:
data = json.loads(self.request.POST['parameters'])
kwargs['parameters'] = data
return kwargs
class DetailView(tabs.TabView):
tab_group_class = StackDetailTabs
template_name = 'project/stacks/detail.html'
def get_context_data(self, **kwargs):
context = super(DetailView, self).get_context_data(**kwargs)
context["stack"] = self.get_data(self.request)
return context
def get_data(self, request, **kwargs):
if not hasattr(self, "_stack"):
stack_id = kwargs['stack_id']
try:
stack = api.heat.stack_get(request, stack_id)
self._stack = stack
except:
msg = _("Unable to retrieve stack.")
redirect = reverse('horizon:project:stacks:index')
exceptions.handle(request, msg, redirect=redirect)
return self._stack
def get_tabs(self, request, **kwargs):
stack = self.get_data(request, **kwargs)
return self.tab_group_class(request, stack=stack, **kwargs)
class ResourceView(tabs.TabView):
tab_group_class = ResourceDetailTabs
template_name = 'project/stacks/resource.html'
def get_context_data(self, **kwargs):
context = super(ResourceView, self).get_context_data(**kwargs)
context["resource"] = self.get_data(self.request, **kwargs)
context["metadata"] = self.get_metadata(self.request, **kwargs)
return context
def get_data(self, request, **kwargs):
if not hasattr(self, "_resource"):
try:
resource = api.heat.resource_get(
request,
kwargs['stack_id'],
kwargs['resource_name'])
self._resource = resource
except:
msg = _("Unable to retrieve resource.")
redirect = reverse('horizon:project:stacks:index')
exceptions.handle(request, msg, redirect=redirect)
return self._resource
def get_metadata(self, request, **kwargs):
if not hasattr(self, "_metadata"):
try:
metadata = api.heat.resource_metadata_get(
request,
kwargs['stack_id'],
kwargs['resource_name'])
self._metadata = json.dumps(metadata, indent=2)
except:
msg = _("Unable to retrieve metadata.")
redirect = reverse('horizon:project:stacks:index')
exceptions.handle(request, msg, redirect=redirect)
return self._metadata
def get_tabs(self, request, **kwargs):
resource = self.get_data(request, **kwargs)
metadata = self.get_metadata(request, **kwargs)
return self.tab_group_class(
request, resource=resource, metadata=metadata, **kwargs)

View File

@ -20,6 +20,7 @@
from cinderclient import exceptions as cinderclient
from glanceclient.common import exceptions as glanceclient
from heatclient import exc as heatclient
from keystoneclient import exceptions as keystoneclient
from novaclient import exceptions as novaclient
from quantumclient.common import exceptions as quantumclient
@ -34,14 +35,17 @@ UNAUTHORIZED = (keystoneclient.Unauthorized,
novaclient.Forbidden,
glanceclient.Unauthorized,
quantumclient.Unauthorized,
quantumclient.Forbidden)
quantumclient.Forbidden,
heatclient.HTTPUnauthorized,
heatclient.HTTPForbidden)
NOT_FOUND = (keystoneclient.NotFound,
cinderclient.NotFound,
novaclient.NotFound,
glanceclient.NotFound,
quantumclient.NetworkNotFoundClient,
quantumclient.PortNotFoundClient)
quantumclient.PortNotFoundClient,
heatclient.HTTPNotFound)
# NOTE(gabriel): This is very broad, and may need to be dialed in.
RECOVERABLE = (keystoneclient.ClientException,
@ -58,4 +62,5 @@ RECOVERABLE = (keystoneclient.ClientException,
quantumclient.PortInUseClient,
quantumclient.AlreadyAttachedClient,
quantumclient.StateInvalidClient,
swiftclient.ClientException)
swiftclient.ClientException,
heatclient.HTTPException)

View File

@ -236,6 +236,10 @@ LOGGING = {
'handlers': ['console'],
'propagate': False,
},
'heatclient': {
'handlers': ['console'],
'propagate': False,
},
'nose.plugins.manager': {
'handlers': ['console'],
'propagate': False,

View File

@ -18,8 +18,304 @@ from heatclient.v1.stacks import StackManager
from .utils import TestDataContainer
# A slightly hacked up copy of a sample cloudformation template for testing.
TEMPLATE = """
{
"AWSTemplateFormatVersion": "2010-09-09",
"Description": "AWS CloudFormation Sample Template.",
"Parameters": {
"KeyName": {
"Description": "Name of an EC2 KeyPair to enable SSH access to the instances",
"Type": "String"
},
"InstanceType": {
"Description": "WebServer EC2 instance type",
"Type": "String",
"Default": "m1.small",
"AllowedValues": [
"m1.tiny",
"m1.small",
"m1.medium",
"m1.large",
"m1.xlarge"
],
"ConstraintDescription": "must be a valid EC2 instance type."
},
"DBName": {
"Default": "wordpress",
"Description": "The WordPress database name",
"Type": "String",
"MinLength": "1",
"MaxLength": "64",
"AllowedPattern": "[a-zA-Z][a-zA-Z0-9]*",
"ConstraintDescription": "must begin with a letter and..."
},
"DBUsername": {
"Default": "admin",
"NoEcho": "true",
"Description": "The WordPress database admin account username",
"Type": "String",
"MinLength": "1",
"MaxLength": "16",
"AllowedPattern": "[a-zA-Z][a-zA-Z0-9]*",
"ConstraintDescription": "must begin with a letter and..."
},
"DBPassword": {
"Default": "admin",
"NoEcho": "true",
"Description": "The WordPress database admin account password",
"Type": "String",
"MinLength": "1",
"MaxLength": "41",
"AllowedPattern": "[a-zA-Z0-9]*",
"ConstraintDescription": "must contain only alphanumeric characters."
},
"DBRootPassword": {
"Default": "admin",
"NoEcho": "true",
"Description": "Root password for MySQL",
"Type": "String",
"MinLength": "1",
"MaxLength": "41",
"AllowedPattern": "[a-zA-Z0-9]*",
"ConstraintDescription": "must contain only alphanumeric characters."
},
"LinuxDistribution": {
"Default": "F17",
"Description": "Distribution of choice",
"Type": "String",
"AllowedValues": [
"F18",
"F17",
"U10",
"RHEL-6.1",
"RHEL-6.2",
"RHEL-6.3"
]
}
},
"Mappings": {
"AWSInstanceType2Arch": {
"m1.tiny": {
"Arch": "32"
},
"m1.small": {
"Arch": "64"
},
"m1.medium": {
"Arch": "64"
},
"m1.large": {
"Arch": "64"
},
"m1.xlarge": {
"Arch": "64"
}
},
"DistroArch2AMI": {
"F18": {
"32": "F18-i386-cfntools",
"64": "F18-x86_64-cfntools"
},
"F17": {
"32": "F17-i386-cfntools",
"64": "F17-x86_64-cfntools"
},
"U10": {
"32": "U10-i386-cfntools",
"64": "U10-x86_64-cfntools"
},
"RHEL-6.1": {
"32": "rhel61-i386-cfntools",
"64": "rhel61-x86_64-cfntools"
},
"RHEL-6.2": {
"32": "rhel62-i386-cfntools",
"64": "rhel62-x86_64-cfntools"
},
"RHEL-6.3": {
"32": "rhel63-i386-cfntools",
"64": "rhel63-x86_64-cfntools"
}
}
},
"Resources": {
"WikiDatabase": {
"Type": "AWS::EC2::Instance",
"Metadata": {
"AWS::CloudFormation::Init": {
"config": {
"packages": {
"yum": {
"mysql": [],
"mysql-server": [],
"httpd": [],
"wordpress": []
}
},
"services": {
"systemd": {
"mysqld": {
"enabled": "true",
"ensureRunning": "true"
},
"httpd": {
"enabled": "true",
"ensureRunning": "true"
}
}
}
}
}
},
"Properties": {
"ImageId": {
"Fn::FindInMap": [
"DistroArch2AMI",
{
"Ref": "LinuxDistribution"
},
{
"Fn::FindInMap": [
"AWSInstanceType2Arch",
{
"Ref": "InstanceType"
},
"Arch"
]
}
]
},
"InstanceType": {
"Ref": "InstanceType"
},
"KeyName": {
"Ref": "KeyName"
},
"UserData": {
"Fn::Base64": {
"Fn::Join": [
"",
[
"#!/bin/bash -v\n",
"/opt/aws/bin/cfn-init\n"
]
]
}
}
}
}
},
"Outputs": {
"WebsiteURL": {
"Value": {
"Fn::Join": [
"",
[
"http://",
{
"Fn::GetAtt": [
"WikiDatabase",
"PublicIp"
]
},
"/wordpress"
]
]
},
"Description": "URL for Wordpress wiki"
}
}
}
"""
VALIDATE = """
{
"Description": "AWS CloudFormation Sample Template.",
"Parameters": {
"DBUsername": {
"Type": "String",
"Description": "The WordPress database admin account username",
"Default": "admin",
"MinLength": "1",
"AllowedPattern": "[a-zA-Z][a-zA-Z0-9]*",
"NoEcho": "true",
"MaxLength": "16",
"ConstraintDescription": "must begin with a letter and..."
},
"LinuxDistribution": {
"Default": "F17",
"Type": "String",
"Description": "Distribution of choice",
"AllowedValues": [
"F18",
"F17",
"U10",
"RHEL-6.1",
"RHEL-6.2",
"RHEL-6.3"
]
},
"DBRootPassword": {
"Type": "String",
"Description": "Root password for MySQL",
"Default": "admin",
"MinLength": "1",
"AllowedPattern": "[a-zA-Z0-9]*",
"NoEcho": "true",
"MaxLength": "41",
"ConstraintDescription": "must contain only alphanumeric characters."
},
"KeyName": {
"Type": "String",
"Description": "Name of an EC2 KeyPair to enable SSH access to the instances"
},
"DBName": {
"Type": "String",
"Description": "The WordPress database name",
"Default": "wordpress",
"MinLength": "1",
"AllowedPattern": "[a-zA-Z][a-zA-Z0-9]*",
"MaxLength": "64",
"ConstraintDescription": "must begin with a letter and..."
},
"DBPassword": {
"Type": "String",
"Description": "The WordPress database admin account password",
"Default": "admin",
"MinLength": "1",
"AllowedPattern": "[a-zA-Z0-9]*",
"NoEcho": "true",
"MaxLength": "41",
"ConstraintDescription": "must contain only alphanumeric characters."
},
"InstanceType": {
"Default": "m1.small",
"Type": "String",
"ConstraintDescription": "must be a valid EC2 instance type.",
"Description": "WebServer EC2 instance type",
"AllowedValues": [
"m1.tiny",
"m1.small",
"m1.medium",
"m1.large",
"m1.xlarge"
]
}
}
}
"""
class Template(object):
def __init__(self, data, validate):
self.data = data
self.validate = validate
def data(TEST):
TEST.stacks = TestDataContainer()
TEST.stack_templates = TestDataContainer()
# Stacks
stack1 = {
@ -32,7 +328,7 @@ def data(TEST):
"rel": "self"
}],
"stack_status_reason": "Stack successfully created",
"stack_name": "stack-1211-38",
"stack_name": "stack-test",
"creation_time": "2013-04-22T00:11:39Z",
"updated_time": "2013-04-22T00:11:39Z",
"stack_status": "CREATE_COMPLETE",
@ -40,3 +336,5 @@ def data(TEST):
}
stack = Stack(StackManager(None), stack1)
TEST.stacks.add(stack)
TEST.stack_templates.add(Template(TEMPLATE, VALIDATE))