diff --git a/horizon/forms/views.py b/horizon/forms/views.py index e942227ac1..b37c4d254a 100644 --- a/horizon/forms/views.py +++ b/horizon/forms/views.py @@ -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) diff --git a/openstack_dashboard/dashboards/project/containers/views.py b/openstack_dashboard/dashboards/project/containers/views.py index 7a614b9f21..2877746834 100644 --- a/openstack_dashboard/dashboards/project/containers/views.py +++ b/openstack_dashboard/dashboards/project/containers/views.py @@ -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)) diff --git a/openstack_dashboard/dashboards/project/dashboard.py b/openstack_dashboard/dashboards/project/dashboard.py index 021fcd1728..673acdd3c1 100644 --- a/openstack_dashboard/dashboards/project/dashboard.py +++ b/openstack_dashboard/dashboards/project/dashboard.py @@ -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 diff --git a/openstack_dashboard/dashboards/project/stacks/__init__.py b/openstack_dashboard/dashboards/project/stacks/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openstack_dashboard/dashboards/project/stacks/forms.py b/openstack_dashboard/dashboards/project/stacks/forms.py new file mode 100644 index 0000000000..3221035d12 --- /dev/null +++ b/openstack_dashboard/dashboards/project/stacks/forms.py @@ -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.')) diff --git a/openstack_dashboard/dashboards/project/stacks/mappings.py b/openstack_dashboard/dashboards/project/stacks/mappings.py new file mode 100644 index 0000000000..5b4f9ec7e4 --- /dev/null +++ b/openstack_dashboard/dashboards/project/stacks/mappings.py @@ -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'
%s
' % json.dumps(output, indent=2) + if isinstance(output, basestring): + parts = urlparse.urlsplit(output) + if parts.netloc and parts.scheme in ('http', 'https'): + return u'%s' % (output, output) + return unicode(output) diff --git a/openstack_dashboard/dashboards/project/stacks/panel.py b/openstack_dashboard/dashboards/project/stacks/panel.py new file mode 100644 index 0000000000..7d9765732d --- /dev/null +++ b/openstack_dashboard/dashboards/project/stacks/panel.py @@ -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) diff --git a/openstack_dashboard/dashboards/project/stacks/tables.py b/openstack_dashboard/dashboards/project/stacks/tables.py new file mode 100644 index 0000000000..73c7215a39 --- /dev/null +++ b/openstack_dashboard/dashboards/project/stacks/tables.py @@ -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 diff --git a/openstack_dashboard/dashboards/project/stacks/tabs.py b/openstack_dashboard/dashboards/project/stacks/tabs.py new file mode 100644 index 0000000000..d59ca7502c --- /dev/null +++ b/openstack_dashboard/dashboards/project/stacks/tabs.py @@ -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 diff --git a/openstack_dashboard/dashboards/project/stacks/templates/stacks/_create.html b/openstack_dashboard/dashboards/project/stacks/templates/stacks/_create.html new file mode 100644 index 0000000000..ab2c049bbd --- /dev/null +++ b/openstack_dashboard/dashboards/project/stacks/templates/stacks/_create.html @@ -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 %} +
+
+ {% include "horizon/common/_form_fields.html" %} +
+
+
+

{% trans "Description" %}:

+

{% trans "Create a new stack with the provided values." %}

+
+{% endblock %} + +{% block modal-footer %} + + {% trans "Cancel" %} +{% endblock %} diff --git a/openstack_dashboard/dashboards/project/stacks/templates/stacks/_detail_events.html b/openstack_dashboard/dashboards/project/stacks/templates/stacks/_detail_events.html new file mode 100644 index 0000000000..9976f88dd9 --- /dev/null +++ b/openstack_dashboard/dashboards/project/stacks/templates/stacks/_detail_events.html @@ -0,0 +1,3 @@ +{% load i18n %} + +{{ table.render }} diff --git a/openstack_dashboard/dashboards/project/stacks/templates/stacks/_detail_overview.html b/openstack_dashboard/dashboards/project/stacks/templates/stacks/_detail_overview.html new file mode 100644 index 0000000000..f4756e07aa --- /dev/null +++ b/openstack_dashboard/dashboards/project/stacks/templates/stacks/_detail_overview.html @@ -0,0 +1,66 @@ +{% load i18n sizeformat %} + +

{% trans "Stack Overview" %}

+ +
+

{% trans "Info" %}

+
+
+
{% trans "Name" %}
+
{{ stack.stack_name }}
+
{% trans "ID" %}
+
{{ stack.id }}
+
{% trans "Description" %}
+
{{ stack.description }}
+
+
+ +
+

{% trans "Status" %}

+
+
+
{% trans "Created" %}
+
{{ stack.creation_time|parse_isotime|timesince }}
+
{% trans "Last Updated" %}
+
{{ stack.updated_time|parse_isotime|timesince }}
+
{% trans "Status" %}
+
{{ stack.stack_status|title }}: {{ stack.stack_status_reason }}
+
+
+ +
+

{% trans "Outputs" %}

+
+
+ {% for output in stack.outputs %} +
{{ output.output_key }}
+
{{ output.description }}
+
+ {% autoescape off %} + {{ output.output_value|stack_output }} + {% endautoescape %}
+ {% endfor %} +
+
+ +
+

{% trans "Stack Parameters" %}

+
+
+ {% for key, value in stack.parameters.items %} +
{{ key }}
+
{{ value }}
+ {% endfor %} +
+
+ +
+

{% trans "Launch Parameters" %}

+
+
+
{% trans "Timeout" %}
+
{{ stack.timeout_mins }} {% trans "Minutes" %}
+
{% trans "Rollback" %}
+
{% if stack.disable_rollback %}{% trans "Disabled" %}{% else %}{% trans "Enabled" %}{% endif %}
+
+
diff --git a/openstack_dashboard/dashboards/project/stacks/templates/stacks/_detail_resources.html b/openstack_dashboard/dashboards/project/stacks/templates/stacks/_detail_resources.html new file mode 100644 index 0000000000..9976f88dd9 --- /dev/null +++ b/openstack_dashboard/dashboards/project/stacks/templates/stacks/_detail_resources.html @@ -0,0 +1,3 @@ +{% load i18n %} + +{{ table.render }} diff --git a/openstack_dashboard/dashboards/project/stacks/templates/stacks/_resource_overview.html b/openstack_dashboard/dashboards/project/stacks/templates/stacks/_resource_overview.html new file mode 100644 index 0000000000..952c8ae489 --- /dev/null +++ b/openstack_dashboard/dashboards/project/stacks/templates/stacks/_resource_overview.html @@ -0,0 +1,42 @@ +{% load i18n sizeformat %} + +

{% trans "Resource Overview" %}

+ +
+

{% trans "Info" %}

+
+
+
{% trans "Stack Resource ID" %}
+
{{ resource.logical_resource_id }}
+
+
+
{% trans "Resource ID" %}
+
{{ resource.physical_resource_id }}
+
+
+
{% trans "Stack Resource Type" %}
+
{{ resource.resource_type }}
+
+
+
{% trans "Description" %}
+
{{ resource.description }}
+
+
+ +
+

{% trans "Status" %}

+
+
+
{% trans "Last Updated" %}
+
{{ resource.updated_time|parse_isotime|timesince }}
+
{% trans "Status" %}
+
{{ resource.resource_status|title|replace_underscores }}: {{ resource.resource_status_reason }}
+
+
+ +
+

{% trans "Resource Metadata" %}

+
+
{{ metadata }}
+  
+
diff --git a/openstack_dashboard/dashboards/project/stacks/templates/stacks/_select_template.html b/openstack_dashboard/dashboards/project/stacks/templates/stacks/_select_template.html new file mode 100644 index 0000000000..c1b6261f5f --- /dev/null +++ b/openstack_dashboard/dashboards/project/stacks/templates/stacks/_select_template.html @@ -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 %} +
+
+ {% include "horizon/common/_form_fields.html" %} +
+
+
+

{% trans "Description" %}:

+

{% trans "Use one of the available template source options to specify the template to be used in creating this stack." %}

+
+{% endblock %} + +{% block modal-footer %} + + {% trans "Cancel" %} +{% endblock %} diff --git a/openstack_dashboard/dashboards/project/stacks/templates/stacks/create.html b/openstack_dashboard/dashboards/project/stacks/templates/stacks/create.html new file mode 100644 index 0000000000..67c8f2c6ee --- /dev/null +++ b/openstack_dashboard/dashboards/project/stacks/templates/stacks/create.html @@ -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 %} diff --git a/openstack_dashboard/dashboards/project/stacks/templates/stacks/detail.html b/openstack_dashboard/dashboards/project/stacks/templates/stacks/detail.html new file mode 100644 index 0000000000..7502167f9c --- /dev/null +++ b/openstack_dashboard/dashboards/project/stacks/templates/stacks/detail.html @@ -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 %} +
+
+ {{ tab_group.render }} +
+
+{% endblock %} diff --git a/openstack_dashboard/dashboards/project/stacks/templates/stacks/index.html b/openstack_dashboard/dashboards/project/stacks/templates/stacks/index.html new file mode 100644 index 0000000000..143917adb0 --- /dev/null +++ b/openstack_dashboard/dashboards/project/stacks/templates/stacks/index.html @@ -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 %} + + diff --git a/openstack_dashboard/dashboards/project/stacks/templates/stacks/resource.html b/openstack_dashboard/dashboards/project/stacks/templates/stacks/resource.html new file mode 100644 index 0000000000..a9ea3021be --- /dev/null +++ b/openstack_dashboard/dashboards/project/stacks/templates/stacks/resource.html @@ -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 %} +
+
+ {{ tab_group.render }} +
+
+{% endblock %} diff --git a/openstack_dashboard/dashboards/project/stacks/templates/stacks/select_template.html b/openstack_dashboard/dashboards/project/stacks/templates/stacks/select_template.html new file mode 100644 index 0000000000..c55848d998 --- /dev/null +++ b/openstack_dashboard/dashboards/project/stacks/templates/stacks/select_template.html @@ -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 %} diff --git a/openstack_dashboard/dashboards/project/stacks/tests.py b/openstack_dashboard/dashboards/project/stacks/tests.py new file mode 100644 index 0000000000..1120509d8d --- /dev/null +++ b/openstack_dashboard/dashboards/project/stacks/tests.py @@ -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'
[\n  "one", \n  "two", \n  "three"\n]
', + mappings.stack_output(['one', 'two', 'three'])) + self.assertEqual( + u'
{\n  "foo": "bar"\n}
', + mappings.stack_output({'foo': 'bar'})) + + self.assertEqual( + u'' + 'http://www.example.com/foo', + 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) diff --git a/openstack_dashboard/dashboards/project/stacks/urls.py b/openstack_dashboard/dashboards/project/stacks/urls.py new file mode 100644 index 0000000000..7fe337c587 --- /dev/null +++ b/openstack_dashboard/dashboards/project/stacks/urls.py @@ -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[^/]+)/$', DetailView.as_view(), name='detail'), + url(r'^stack/(?P[^/]+)/(?P[^/]+)/$', + ResourceView.as_view(), name='resource'), +) diff --git a/openstack_dashboard/dashboards/project/stacks/views.py b/openstack_dashboard/dashboards/project/stacks/views.py new file mode 100644 index 0000000000..f3bcc4084d --- /dev/null +++ b/openstack_dashboard/dashboards/project/stacks/views.py @@ -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) diff --git a/openstack_dashboard/exceptions.py b/openstack_dashboard/exceptions.py index 8801fc982b..81a3e87781 100644 --- a/openstack_dashboard/exceptions.py +++ b/openstack_dashboard/exceptions.py @@ -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) diff --git a/openstack_dashboard/local/local_settings.py.example b/openstack_dashboard/local/local_settings.py.example index a5537d120a..19e8167361 100644 --- a/openstack_dashboard/local/local_settings.py.example +++ b/openstack_dashboard/local/local_settings.py.example @@ -236,6 +236,10 @@ LOGGING = { 'handlers': ['console'], 'propagate': False, }, + 'heatclient': { + 'handlers': ['console'], + 'propagate': False, + }, 'nose.plugins.manager': { 'handlers': ['console'], 'propagate': False, diff --git a/openstack_dashboard/test/test_data/heat_data.py b/openstack_dashboard/test/test_data/heat_data.py index e72bcc5122..d3c8ebbc35 100644 --- a/openstack_dashboard/test/test_data/heat_data.py +++ b/openstack_dashboard/test/test_data/heat_data.py @@ -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))