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:
parent
5212992100
commit
1f71152b4b
@ -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)
|
||||
|
@ -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))
|
||||
|
@ -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
|
||||
|
||||
|
248
openstack_dashboard/dashboards/project/stacks/forms.py
Normal file
248
openstack_dashboard/dashboards/project/stacks/forms.py
Normal 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.'))
|
83
openstack_dashboard/dashboards/project/stacks/mappings.py
Normal file
83
openstack_dashboard/dashboards/project/stacks/mappings.py
Normal 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)
|
27
openstack_dashboard/dashboards/project/stacks/panel.py
Normal file
27
openstack_dashboard/dashboards/project/stacks/panel.py
Normal 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)
|
179
openstack_dashboard/dashboards/project/stacks/tables.py
Normal file
179
openstack_dashboard/dashboards/project/stacks/tables.py
Normal 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
|
100
openstack_dashboard/dashboards/project/stacks/tabs.py
Normal file
100
openstack_dashboard/dashboards/project/stacks/tabs.py
Normal 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
|
@ -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 %}
|
@ -0,0 +1,3 @@
|
||||
{% load i18n %}
|
||||
|
||||
{{ table.render }}
|
@ -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>
|
@ -0,0 +1,3 @@
|
||||
{% load i18n %}
|
||||
|
||||
{{ table.render }}
|
@ -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>
|
@ -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 %}
|
@ -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 %}
|
@ -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 %}
|
@ -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 %}
|
||||
|
||||
|
@ -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 %}
|
@ -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 %}
|
154
openstack_dashboard/dashboards/project/stacks/tests.py
Normal file
154
openstack_dashboard/dashboards/project/stacks/tests.py
Normal 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)
|
34
openstack_dashboard/dashboards/project/stacks/urls.py
Normal file
34
openstack_dashboard/dashboards/project/stacks/urls.py
Normal 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'),
|
||||
)
|
157
openstack_dashboard/dashboards/project/stacks/views.py
Normal file
157
openstack_dashboard/dashboards/project/stacks/views.py
Normal 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)
|
@ -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)
|
||||
|
@ -236,6 +236,10 @@ LOGGING = {
|
||||
'handlers': ['console'],
|
||||
'propagate': False,
|
||||
},
|
||||
'heatclient': {
|
||||
'handlers': ['console'],
|
||||
'propagate': False,
|
||||
},
|
||||
'nose.plugins.manager': {
|
||||
'handlers': ['console'],
|
||||
'propagate': False,
|
||||
|
@ -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))
|
||||
|
Loading…
Reference in New Issue
Block a user