From 3b0f8570def8a70245e454f60043aa86e375a8a8 Mon Sep 17 00:00:00 2001 From: Timur Nurlygayanov Date: Wed, 13 Feb 2013 20:49:28 +0400 Subject: [PATCH] Added initial project for horizon dashboard --- dashboard/ReadMe.txt | 30 ++ dashboard/windc/__init__.py | 0 dashboard/windc/forms.py | 52 ++ dashboard/windc/panel.py | 29 ++ dashboard/windc/tables.py | 278 +++++++++++ dashboard/windc/tabs.py | 85 ++++ dashboard/windc/templates/windc/create.html | 11 + dashboard/windc/templates/windc/index.html | 11 + dashboard/windc/urls.py | 31 ++ dashboard/windc/views.py | 95 ++++ dashboard/windc/workflows.py | 514 ++++++++++++++++++++ 11 files changed, 1136 insertions(+) create mode 100644 dashboard/ReadMe.txt create mode 100644 dashboard/windc/__init__.py create mode 100644 dashboard/windc/forms.py create mode 100644 dashboard/windc/panel.py create mode 100644 dashboard/windc/tables.py create mode 100644 dashboard/windc/tabs.py create mode 100644 dashboard/windc/templates/windc/create.html create mode 100644 dashboard/windc/templates/windc/index.html create mode 100644 dashboard/windc/urls.py create mode 100644 dashboard/windc/views.py create mode 100644 dashboard/windc/workflows.py diff --git a/dashboard/ReadMe.txt b/dashboard/ReadMe.txt new file mode 100644 index 0000000..39e951e --- /dev/null +++ b/dashboard/ReadMe.txt @@ -0,0 +1,30 @@ +# TO DO: +# 1. Fix issue with Create button +# 2. Create simple form for Windows Data Center deploy +# 3. Remove extra code +# + +This file is described how to install new tab on horizon dashboard. +We should do the following: + 1. Copy directory 'windc' to directory '/opt/stack/horizon/openstack_dashboard/dashboards/project' + 2. Edit file '/opt/stack/horizon/openstack_dashboard/dashboards/project/dashboard.py' + Add line with windc project: + + ... +class BasePanels(horizon.PanelGroup): + slug = "compute" + name = _("Manage Compute") + panels = ('overview', + 'instances', + 'volumes', + 'images_and_snapshots', + 'access_and_security', + 'networks', + 'routers', + 'windc') + + ... + + 3. Run the test Django server: + cd /opt/stack/horizon + python manage.py runserver 67.207.197.36:8080 \ No newline at end of file diff --git a/dashboard/windc/__init__.py b/dashboard/windc/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/dashboard/windc/forms.py b/dashboard/windc/forms.py new file mode 100644 index 0000000..518a9a0 --- /dev/null +++ b/dashboard/windc/forms.py @@ -0,0 +1,52 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2012 United States Government as represented by the +# Administrator of the National Aeronautics and Space Administration. +# All Rights Reserved. +# +# Copyright 2012 Nebula, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import logging + +from django.core.urlresolvers import reverse +from django.utils.translation import ugettext_lazy as _ + +from openstack_dashboard import api + +from horizon import exceptions +from horizon import forms +from horizon import messages + + +LOG = logging.getLogger(__name__) + + +class UpdateInstance(forms.SelfHandlingForm): + tenant_id = forms.CharField(widget=forms.HiddenInput) + instance = forms.CharField(widget=forms.HiddenInput) + name = forms.CharField(required=True) + + def handle(self, request, data): + try: + server = api.nova.server_update(request, data['instance'], + data['name']) + messages.success(request, + _('Instance "%s" updated.') % data['name']) + return server + except: + redirect = reverse("horizon:project:instances:index") + exceptions.handle(request, + _('Unable to update instance.'), + redirect=redirect) diff --git a/dashboard/windc/panel.py b/dashboard/windc/panel.py new file mode 100644 index 0000000..cc464af --- /dev/null +++ b/dashboard/windc/panel.py @@ -0,0 +1,29 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2012 Nebula, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from django.utils.translation import ugettext_lazy as _ + +import horizon + +from openstack_dashboard.dashboards.project import dashboard + + +class WinDC(horizon.Panel): + name = _("WinDC") + slug = 'windc' + + +dashboard.Project.register(WinDC) diff --git a/dashboard/windc/tables.py b/dashboard/windc/tables.py new file mode 100644 index 0000000..d2aad13 --- /dev/null +++ b/dashboard/windc/tables.py @@ -0,0 +1,278 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2012 Nebula, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import logging + +from django import shortcuts +from django import template +from django.core import urlresolvers +from django.template.defaultfilters import title +from django.utils.http import urlencode +from django.utils.translation import string_concat, ugettext_lazy as _ + +from horizon.conf import HORIZON_CONFIG +from horizon import exceptions +from horizon import messages +from horizon import tables +from horizon.templatetags import sizeformat +from horizon.utils.filters import replace_underscores + +from openstack_dashboard import api +from openstack_dashboard.dashboards.project.access_and_security \ + .floating_ips.workflows import IPAssociationWorkflow +from .tabs import InstanceDetailTabs, LogTab, ConsoleTab + + +LOG = logging.getLogger(__name__) + +ACTIVE_STATES = ("ACTIVE",) + +POWER_STATES = { + 0: "NO STATE", + 1: "RUNNING", + 2: "BLOCKED", + 3: "PAUSED", + 4: "SHUTDOWN", + 5: "SHUTOFF", + 6: "CRASHED", + 7: "SUSPENDED", + 8: "FAILED", + 9: "BUILDING", +} + +PAUSE = 0 +UNPAUSE = 1 +SUSPEND = 0 +RESUME = 1 + + +def is_deleting(instance): + task_state = getattr(instance, "OS-EXT-STS:task_state", None) + if not task_state: + return False + return task_state.lower() == "deleting" + + +class RebootWinDC(tables.BatchAction): + name = "reboot" + action_present = _("Reboot") + action_past = _("Rebooted") + data_type_singular = _("Instance") + data_type_plural = _("Instances") + classes = ('btn-danger', 'btn-reboot') + + def allowed(self, request, instance=None): + return ((instance.status in ACTIVE_STATES + or instance.status == 'SHUTOFF') + and not is_deleting(instance)) + + def action(self, request, obj_id): + api.nova.server_reboot(request, obj_id) + + +class CreateWinDC(tables.LinkAction): + name = "CreateWinDC" + verbose_name = _("Create WinDC") + url = "horizon:project:windc:create" + classes = ("btn-launch", "ajax-modal") + + def allowed(self, request, datum): + try: + limits = api.nova.tenant_absolute_limits(request, reserved=True) + + instances_available = limits['maxTotalInstances'] \ + - limits['totalInstancesUsed'] + cores_available = limits['maxTotalCores'] \ + - limits['totalCoresUsed'] + ram_available = limits['maxTotalRAMSize'] - limits['totalRAMUsed'] + + if instances_available <= 0 or cores_available <= 0 \ + or ram_available <= 0: + if "disabled" not in self.classes: + self.classes = [c for c in self.classes] + ['disabled'] + self.verbose_name = string_concat(self.verbose_name, ' ', + _("(Quota exceeded)")) + else: + self.verbose_name = _("Create WinDC") + classes = [c for c in self.classes if c != "disabled"] + self.classes = classes + except: + LOG.exception("Failed to retrieve quota information") + # If we can't get the quota information, leave it to the + # API to check when launching + + return True # The action should always be displayed + + +class DeleteWinDC(tables.BatchAction): + name = "DeleteWinDC" + action_present = _("DeleteWinDC") + action_past = _("Scheduled termination of") + data_type_singular = _("Instance") + data_type_plural = _("Instances") + classes = ('btn-danger', 'btn-terminate') + + def allowed(self, request, instance=None): + if instance: + # FIXME(gabriel): This is true in Essex, but in FOLSOM an instance + # can be terminated in any state. We should improve this error + # handling when LP bug 1037241 is implemented. + return instance.status not in ("PAUSED", "SUSPENDED") + return True + + def action(self, request, obj_id): + api.nova.server_delete(request, obj_id) + + +class EditWinDC(tables.LinkAction): + name = "edit" + verbose_name = _("Edit Instance") + url = "horizon:project:instances:update" + classes = ("ajax-modal", "btn-edit") + + def allowed(self, request, instance): + return not is_deleting(instance) + + +class ConsoleLink(tables.LinkAction): + name = "console" + verbose_name = _("Console") + url = "horizon:project:instances:detail" + classes = ("btn-console",) + + def allowed(self, request, instance=None): + return instance.status in ACTIVE_STATES and not is_deleting(instance) + + def get_link_url(self, datum): + base_url = super(ConsoleLink, self).get_link_url(datum) + tab_query_string = ConsoleTab(InstanceDetailTabs).get_query_string() + return "?".join([base_url, tab_query_string]) + + +class LogLink(tables.LinkAction): + name = "log" + verbose_name = _("View Log") + url = "horizon:project:instances:detail" + classes = ("btn-log",) + + def allowed(self, request, instance=None): + return instance.status in ACTIVE_STATES and not is_deleting(instance) + + def get_link_url(self, datum): + base_url = super(LogLink, self).get_link_url(datum) + tab_query_string = LogTab(InstanceDetailTabs).get_query_string() + return "?".join([base_url, tab_query_string]) + + +class UpdateRow(tables.Row): + ajax = True + + def get_data(self, request, instance_id): + instance = api.nova.server_get(request, instance_id) + instance.full_flavor = api.nova.flavor_get(request, + instance.flavor["id"]) + return instance + + +def get_ips(instance): + template_name = 'project/instances/_instance_ips.html' + context = {"instance": instance} + return template.loader.render_to_string(template_name, context) + + +def get_size(instance): + if hasattr(instance, "full_flavor"): + size_string = _("%(name)s | %(RAM)s RAM | %(VCPU)s VCPU " + "| %(disk)s Disk") + vals = {'name': instance.full_flavor.name, + 'RAM': sizeformat.mbformat(instance.full_flavor.ram), + 'VCPU': instance.full_flavor.vcpus, + 'disk': sizeformat.diskgbformat(instance.full_flavor.disk)} + return size_string % vals + return _("Not available") + + +def get_keyname(instance): + if hasattr(instance, "key_name"): + keyname = instance.key_name + return keyname + return _("Not available") + + +def get_power_state(instance): + return POWER_STATES.get(getattr(instance, "OS-EXT-STS:power_state", 0), '') + + +STATUS_DISPLAY_CHOICES = ( + ("resize", "Resize/Migrate"), + ("verify_resize", "Confirm or Revert Resize/Migrate"), + ("revert_resize", "Revert Resize/Migrate"), +) + + +TASK_DISPLAY_CHOICES = ( + ("image_snapshot", "Snapshotting"), + ("resize_prep", "Preparing Resize or Migrate"), + ("resize_migrating", "Resizing or Migrating"), + ("resize_migrated", "Resized or Migrated"), + ("resize_finish", "Finishing Resize or Migrate"), + ("resize_confirming", "Confirming Resize or Nigrate"), + ("resize_reverting", "Reverting Resize or Migrate"), + ("unpausing", "Resuming"), +) + + +class WinDCTable(tables.DataTable): + TASK_STATUS_CHOICES = ( + (None, True), + ("none", True) + ) + STATUS_CHOICES = ( + ("active", True), + ("shutoff", True), + ("error", False), + ) + name = tables.Column("name", + link=("horizon:project:instances:detail"), + verbose_name=_("WinDC Instance Name")) + ip = tables.Column(get_ips, verbose_name=_("IP Address")) + size = tables.Column(get_size, + verbose_name=_("Type"), + attrs={'data-type': 'type'}) + keypair = tables.Column(get_keyname, verbose_name=_("Keypair")) + status = tables.Column("status", + filters=(title, replace_underscores), + verbose_name=_("Status"), + status=True, + status_choices=STATUS_CHOICES, + display_choices=STATUS_DISPLAY_CHOICES) + task = tables.Column("OS-EXT-STS:task_state", + verbose_name=_("Task"), + filters=(title, replace_underscores), + status=True, + status_choices=TASK_STATUS_CHOICES, + display_choices=TASK_DISPLAY_CHOICES) + state = tables.Column(get_power_state, + filters=(title, replace_underscores), + verbose_name=_("Power State")) + + class Meta: + name = "windc" + verbose_name = _("WinDC") + status_columns = ["status", "task"] + row_class = UpdateRow + table_actions = (CreateWinDC, DeleteWinDC) + row_actions = (EditWinDC, ConsoleLink, LogLink, RebootWinDC) diff --git a/dashboard/windc/tabs.py b/dashboard/windc/tabs.py new file mode 100644 index 0000000..029c877 --- /dev/null +++ b/dashboard/windc/tabs.py @@ -0,0 +1,85 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2012 Nebula, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from django.utils.translation import ugettext_lazy as _ + +from horizon import exceptions +from horizon import tabs + +from openstack_dashboard import api + + +class OverviewTab(tabs.Tab): + name = _("Overview") + slug = "overview" + template_name = ("project/instances/" + "_detail_overview.html") + + def get_context_data(self, request): + return {"instance": self.tab_group.kwargs['instance']} + + +class LogTab(tabs.Tab): + name = _("Log") + slug = "log" + template_name = "project/instances/_detail_log.html" + preload = False + + def get_context_data(self, request): + instance = self.tab_group.kwargs['instance'] + try: + data = api.nova.server_console_output(request, + instance.id, + tail_length=35) + except: + data = _('Unable to get log for instance "%s".') % instance.id + exceptions.handle(request, ignore=True) + return {"instance": instance, + "console_log": data} + + +class ConsoleTab(tabs.Tab): + name = _("Console") + slug = "console" + template_name = "project/instances/_detail_console.html" + preload = False + + def get_context_data(self, request): + instance = self.tab_group.kwargs['instance'] + # Currently prefer VNC over SPICE, since noVNC has had much more + # testing than spice-html5 + try: + console = api.nova.server_vnc_console(request, instance.id) + console_url = "%s&title=%s(%s)" % ( + console.url, + getattr(instance, "name", ""), + instance.id) + except: + try: + console = api.nova.server_spice_console(request, instance.id) + console_url = "%s&title=%s(%s)" % ( + console.url, + getattr(instance, "name", ""), + instance.id) + except: + console_url = None + return {'console_url': console_url, 'instance_id': instance.id} + + +class InstanceDetailTabs(tabs.TabGroup): + slug = "instance_details" + tabs = (OverviewTab, LogTab, ConsoleTab) + sticky = True diff --git a/dashboard/windc/templates/windc/create.html b/dashboard/windc/templates/windc/create.html new file mode 100644 index 0000000..cc5e244 --- /dev/null +++ b/dashboard/windc/templates/windc/create.html @@ -0,0 +1,11 @@ +{% extends 'base.html' %} +{% load i18n %} +{% block title %}{% trans "Create WinDC" %}{% endblock %} + +{% block page_header %} + {% include "horizon/common/_page_header.html" with title=_("CreateWinDC") %} +{% endblock page_header %} + +{% block main %} + {% include 'horizon/common/_workflow.html' %} +{% endblock %} diff --git a/dashboard/windc/templates/windc/index.html b/dashboard/windc/templates/windc/index.html new file mode 100644 index 0000000..13d24d8 --- /dev/null +++ b/dashboard/windc/templates/windc/index.html @@ -0,0 +1,11 @@ +{% extends 'base.html' %} +{% load i18n %} +{% block title %}{% trans "WinDC" %}{% endblock %} + +{% block page_header %} + {% include "horizon/common/_page_header.html" with title=_("WinDC") %} +{% endblock page_header %} + +{% block main %} + {{ table.render }} +{% endblock %} diff --git a/dashboard/windc/urls.py b/dashboard/windc/urls.py new file mode 100644 index 0000000..ec6314c --- /dev/null +++ b/dashboard/windc/urls.py @@ -0,0 +1,31 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2012 United States Government as represented by the +# Administrator of the National Aeronautics and Space Administration. +# All Rights Reserved. +# +# Copyright 2012 Nebula, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from django.conf.urls.defaults import patterns, url + +from .views import IndexView, CreateWinDCView + + +VIEW_MOD = 'openstack_dashboard.dashboards.project.windc.views' + +urlpatterns = patterns(VIEW_MOD, + url(r'^$', IndexView.as_view(), name='index'), + url(r'^create/$', CreateWinDCView.as_view(), name='CreateWinDC') +) diff --git a/dashboard/windc/views.py b/dashboard/windc/views.py new file mode 100644 index 0000000..a5fd2f6 --- /dev/null +++ b/dashboard/windc/views.py @@ -0,0 +1,95 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2012 United States Government as represented by the +# Administrator of the National Aeronautics and Space Administration. +# All Rights Reserved. +# +# Copyright 2012 Nebula, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +""" +Views for managing instances. +""" +import logging + +from django import http +from django import shortcuts +from django.core.urlresolvers import reverse, reverse_lazy +from django.utils.datastructures import SortedDict +from django.utils.translation import ugettext_lazy as _ + +from horizon import exceptions +from horizon import forms +from horizon import tabs +from horizon import tables +from horizon import workflows + +from openstack_dashboard import api +from .forms import UpdateInstance +from .tabs import InstanceDetailTabs +from .tables import WinDCTable +from .workflows import CreateWinDC + + +LOG = logging.getLogger(__name__) + + +class IndexView(tables.DataTableView): + table_class = WinDCTable + template_name = 'project/windc/index.html' + + def get_data(self): + # Gather our instances + try: + instances = api.nova.server_list(self.request) + except: + instances = [] + exceptions.handle(self.request, + _('Unable to retrieve instances.')) + # Gather our flavors and correlate our instances to them + if instances: + try: + flavors = api.nova.flavor_list(self.request) + except: + flavors = [] + exceptions.handle(self.request, ignore=True) + + full_flavors = SortedDict([(str(flavor.id), flavor) + for flavor in flavors]) + # Loop through instances to get flavor info. + for instance in instances: + try: + flavor_id = instance.flavor["id"] + if flavor_id in full_flavors: + instance.full_flavor = full_flavors[flavor_id] + else: + # If the flavor_id is not in full_flavors list, + # get it via nova api. + instance.full_flavor = api.nova.flavor_get( + self.request, flavor_id) + except: + msg = _('Unable to retrieve instance size information.') + exceptions.handle(self.request, msg) + return instances + + +class CreateWinDCView(workflows.WorkflowView): + workflow_class = CreateWinDC + template_name = "project/windc/create.html" + + def get_initial(self): + initial = super(CreateWinDCView, self).get_initial() + initial['project_id'] = self.request.user.tenant_id + initial['user_id'] = self.request.user.id + return initial diff --git a/dashboard/windc/workflows.py b/dashboard/windc/workflows.py new file mode 100644 index 0000000..a282eec --- /dev/null +++ b/dashboard/windc/workflows.py @@ -0,0 +1,514 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2012 United States Government as represented by the +# Administrator of the National Aeronautics and Space Administration. +# All Rights Reserved. +# +# Copyright 2012 Nebula, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import json +import logging + +from django.utils.text import normalize_newlines +from django.utils.translation import ugettext as _ + +from horizon import exceptions +from horizon import forms +from horizon import workflows + +from openstack_dashboard import api +from openstack_dashboard.api import cinder +from openstack_dashboard.api import glance +from openstack_dashboard.usage import quotas + + +LOG = logging.getLogger(__name__) + + +class SelectProjectUserAction(workflows.Action): + project_id = forms.ChoiceField(label=_("Project")) + user_id = forms.ChoiceField(label=_("User")) + + def __init__(self, request, *args, **kwargs): + super(SelectProjectUserAction, self).__init__(request, *args, **kwargs) + # Set our project choices + projects = [(tenant.id, tenant.name) + for tenant in request.user.authorized_tenants] + self.fields['project_id'].choices = projects + + # Set our user options + users = [(request.user.id, request.user.username)] + self.fields['user_id'].choices = users + + class Meta: + name = _("Project & User") + # Unusable permission so this is always hidden. However, we + # keep this step in the workflow for validation/verification purposes. + permissions = ("!",) + + +class SelectProjectUser(workflows.Step): + action_class = SelectProjectUserAction + contributes = ("project_id", "user_id") + + +class VolumeOptionsAction(workflows.Action): + VOLUME_CHOICES = ( + ('', _("Don't boot from a volume.")), + ("volume_id", _("Boot from volume.")), + ("volume_snapshot_id", _("Boot from volume snapshot " + "(creates a new volume).")), + ) + # Boot from volume options + volume_type = forms.ChoiceField(label=_("Volume Options"), + choices=VOLUME_CHOICES, + required=False) + volume_id = forms.ChoiceField(label=_("Volume"), required=False) + volume_snapshot_id = forms.ChoiceField(label=_("Volume Snapshot"), + required=False) + device_name = forms.CharField(label=_("Device Name"), + required=False, + initial="vda", + help_text=_("Volume mount point (e.g. 'vda' " + "mounts at '/dev/vda').")) + delete_on_terminate = forms.BooleanField(label=_("Delete on Terminate"), + initial=False, + required=False, + help_text=_("Delete volume on " + "instance terminate")) + + class Meta: + name = _("Volume Options") + permissions = ('openstack.services.volume',) + help_text_template = ("project/instances/" + "_launch_volumes_help.html") + + def clean(self): + cleaned_data = super(VolumeOptionsAction, self).clean() + volume_opt = cleaned_data.get('volume_type', None) + + if volume_opt and not cleaned_data[volume_opt]: + raise forms.ValidationError(_('Please choose a volume, or select ' + '%s.') % self.VOLUME_CHOICES[0][1]) + return cleaned_data + + def _get_volume_display_name(self, volume): + if hasattr(volume, "volume_id"): + vol_type = "snap" + visible_label = _("Snapshot") + else: + vol_type = "vol" + visible_label = _("Volume") + return (("%s:%s" % (volume.id, vol_type)), + ("%s - %s GB (%s)" % (volume.display_name, + volume.size, + visible_label))) + + def populate_volume_id_choices(self, request, context): + volume_options = [("", _("Select Volume"))] + try: + volumes = [v for v in cinder.volume_list(self.request) + if v.status == api.cinder.VOLUME_STATE_AVAILABLE] + volume_options.extend([self._get_volume_display_name(vol) + for vol in volumes]) + except: + exceptions.handle(self.request, + _('Unable to retrieve list of volumes.')) + return volume_options + + def populate_volume_snapshot_id_choices(self, request, context): + volume_options = [("", _("Select Volume Snapshot"))] + try: + snapshots = cinder.volume_snapshot_list(self.request) + snapshots = [s for s in snapshots + if s.status == api.cinder.VOLUME_STATE_AVAILABLE] + volume_options.extend([self._get_volume_display_name(snap) + for snap in snapshots]) + except: + exceptions.handle(self.request, + _('Unable to retrieve list of volume ' + 'snapshots.')) + + return volume_options + + +class VolumeOptions(workflows.Step): + action_class = VolumeOptionsAction + depends_on = ("project_id", "user_id") + contributes = ("volume_type", + "volume_id", + "device_name", # Can be None for an image. + "delete_on_terminate") + + def contribute(self, data, context): + context = super(VolumeOptions, self).contribute(data, context) + # Translate form input to context for volume values. + if "volume_type" in data and data["volume_type"]: + context['volume_id'] = data.get(data['volume_type'], None) + + if not context.get("volume_type", ""): + context['volume_type'] = self.action.VOLUME_CHOICES[0][0] + context['volume_id'] = None + context['device_name'] = None + context['delete_on_terminate'] = None + return context + + +class SetInstanceDetailsAction(workflows.Action): + SOURCE_TYPE_CHOICES = ( + ("image_id", _("Image")), + ("instance_snapshot_id", _("Snapshot")), + ) + source_type = forms.ChoiceField(label=_("Instance Source"), + choices=SOURCE_TYPE_CHOICES) + image_id = forms.ChoiceField(label=_("Image"), required=False) + instance_snapshot_id = forms.ChoiceField(label=_("Instance Snapshot"), + required=False) + name = forms.CharField(max_length=80, label=_("Instance Name")) + flavor = forms.ChoiceField(label=_("Flavor"), + help_text=_("Size of image to launch.")) + count = forms.IntegerField(label=_("Instance Count"), + min_value=1, + initial=1, + help_text=_("Number of instances to launch.")) + + class Meta: + name = _("Details") + help_text_template = ("project/instances/" + "_launch_details_help.html") + + def clean(self): + cleaned_data = super(SetInstanceDetailsAction, self).clean() + + # Validate our instance source. + source = cleaned_data['source_type'] + # There should always be at least one image_id choice, telling the user + # that there are "No Images Available" so we check for 2 here... + if source == 'image_id' and not \ + filter(lambda x: x[0] != '', self.fields['image_id'].choices): + raise forms.ValidationError(_("There are no image sources " + "available; you must first create " + "an image before attempting to " + "launch an instance.")) + if not cleaned_data[source]: + raise forms.ValidationError(_("Please select an option for the " + "instance source.")) + + # Prevent launching multiple instances with the same volume. + # TODO(gabriel): is it safe to launch multiple instances with + # a snapshot since it should be cloned to new volumes? + count = cleaned_data.get('count', 1) + volume_type = self.data.get('volume_type', None) + if volume_type and count > 1: + msg = _('Launching multiple instances is only supported for ' + 'images and instance snapshots.') + raise forms.ValidationError(msg) + + return cleaned_data + + def _get_available_images(self, request, context): + project_id = context.get('project_id', None) + if not hasattr(self, "_public_images"): + public = {"is_public": True, + "status": "active"} + try: + public_images, _more = glance.image_list_detailed( + request, filters=public) + except: + public_images = [] + exceptions.handle(request, + _("Unable to retrieve public images.")) + self._public_images = public_images + + # Preempt if we don't have a project_id yet. + if project_id is None: + setattr(self, "_images_for_%s" % project_id, []) + + if not hasattr(self, "_images_for_%s" % project_id): + owner = {"property-owner_id": project_id, + "status": "active"} + try: + owned_images, _more = glance.image_list_detailed( + request, filters=owner) + except: + exceptions.handle(request, + _("Unable to retrieve images for " + "the current project.")) + setattr(self, "_images_for_%s" % project_id, owned_images) + + owned_images = getattr(self, "_images_for_%s" % project_id) + images = owned_images + self._public_images + + # Remove duplicate images + image_ids = [] + final_images = [] + for image in images: + if image.id not in image_ids: + image_ids.append(image.id) + final_images.append(image) + return [image for image in final_images + if image.container_format not in ('aki', 'ari')] + + def populate_image_id_choices(self, request, context): + images = self._get_available_images(request, context) + choices = [(image.id, image.name) + for image in images + if image.properties.get("image_type", '') != "snapshot"] + if choices: + choices.insert(0, ("", _("Select Image"))) + else: + choices.insert(0, ("", _("No images available."))) + return choices + + def populate_instance_snapshot_id_choices(self, request, context): + images = self._get_available_images(request, context) + choices = [(image.id, image.name) + for image in images + if image.properties.get("image_type", '') == "snapshot"] + if choices: + choices.insert(0, ("", _("Select Instance Snapshot"))) + else: + choices.insert(0, ("", _("No snapshots available."))) + return choices + + def populate_flavor_choices(self, request, context): + try: + flavors = api.nova.flavor_list(request) + flavor_list = [(flavor.id, "%s" % flavor.name) + for flavor in flavors] + except: + flavor_list = [] + exceptions.handle(request, + _('Unable to retrieve instance flavors.')) + return sorted(flavor_list) + + def get_help_text(self): + extra = {} + try: + extra['usages'] = quotas.tenant_quota_usages(self.request) + extra['usages_json'] = json.dumps(extra['usages']) + flavors = json.dumps([f._info for f in + api.nova.flavor_list(self.request)]) + extra['flavors'] = flavors + except: + exceptions.handle(self.request, + _("Unable to retrieve quota information.")) + return super(SetInstanceDetailsAction, self).get_help_text(extra) + + +class SetInstanceDetails(workflows.Step): + action_class = SetInstanceDetailsAction + contributes = ("source_type", "source_id", "name", "count", "flavor") + + def prepare_action_context(self, request, context): + if 'source_type' in context and 'source_id' in context: + context[context['source_type']] = context['source_id'] + return context + + def contribute(self, data, context): + context = super(SetInstanceDetails, self).contribute(data, context) + # Allow setting the source dynamically. + if ("source_type" in context and "source_id" in context + and context["source_type"] not in context): + context[context["source_type"]] = context["source_id"] + + # Translate form input to context for source values. + if "source_type" in data: + context["source_id"] = data.get(data['source_type'], None) + + return context + + +KEYPAIR_IMPORT_URL = "horizon:project:access_and_security:keypairs:import" + + +class SetAccessControlsAction(workflows.Action): + keypair = forms.DynamicChoiceField(label=_("Keypair"), + required=False, + help_text=_("Which keypair to use for " + "authentication."), + add_item_link=KEYPAIR_IMPORT_URL) + groups = forms.MultipleChoiceField(label=_("Security Groups"), + required=True, + initial=["default"], + widget=forms.CheckboxSelectMultiple(), + help_text=_("Launch instance in these " + "security groups.")) + + class Meta: + name = _("Access & Security") + help_text = _("Control access to your instance via keypairs, " + "security groups, and other mechanisms.") + + def populate_keypair_choices(self, request, context): + try: + keypairs = api.nova.keypair_list(request) + keypair_list = [(kp.name, kp.name) for kp in keypairs] + except: + keypair_list = [] + exceptions.handle(request, + _('Unable to retrieve keypairs.')) + if keypair_list: + keypair_list.insert(0, ("", _("Select a keypair"))) + else: + keypair_list = (("", _("No keypairs available.")),) + return keypair_list + + def populate_groups_choices(self, request, context): + try: + groups = api.nova.security_group_list(request) + security_group_list = [(sg.name, sg.name) for sg in groups] + except: + exceptions.handle(request, + _('Unable to retrieve list of security groups')) + security_group_list = [] + return security_group_list + + +class SetAccessControls(workflows.Step): + action_class = SetAccessControlsAction + depends_on = ("project_id", "user_id") + contributes = ("keypair_id", "security_group_ids") + + def contribute(self, data, context): + if data: + post = self.workflow.request.POST + context['security_group_ids'] = post.getlist("groups") + context['keypair_id'] = data.get("keypair", "") + return context + + +class CustomizeAction(workflows.Action): + customization_script = forms.CharField(widget=forms.Textarea, + label=_("Customization Script"), + required=False, + help_text=_("A script or set of " + "commands to be " + "executed after the " + "instance has been " + "built (max 16kb).")) + + class Meta: + name = _("Post-Creation") + help_text_template = ("project/instances/" + "_launch_customize_help.html") + + +class PostCreationStep(workflows.Step): + action_class = CustomizeAction + contributes = ("customization_script",) + + +class SetNetworkAction(workflows.Action): + network = forms.MultipleChoiceField(label=_("Networks"), + required=True, + widget=forms.CheckboxSelectMultiple(), + help_text=_("Launch instance with" + "these networks")) + + class Meta: + name = _("Networking") + permissions = ('openstack.services.network',) + help_text = _("Select networks for your instance.") + + def populate_network_choices(self, request, context): + try: + tenant_id = self.request.user.tenant_id + networks = api.quantum.network_list_for_tenant(request, tenant_id) + for n in networks: + n.set_id_as_name_if_empty() + network_list = [(network.id, network.name) for network in networks] + except: + network_list = [] + exceptions.handle(request, + _('Unable to retrieve networks.')) + return network_list + + +class SetNetwork(workflows.Step): + action_class = SetNetworkAction + contributes = ("network_id",) + + def contribute(self, data, context): + if data: + networks = self.workflow.request.POST.getlist("network") + # If no networks are explicitly specified, network list + # contains an empty string, so remove it. + networks = [n for n in networks if n != ''] + if networks: + context['network_id'] = networks + return context + + +class CreateWinDC(workflows.Workflow): + slug = "create_windc" + name = _("Create WinDC Instance") + finalize_button_name = _("Deploy") + success_message = _('Deployed %(count)s named "%(name)s".') + failure_message = _('Unable to deploy %(count)s named "%(name)s".') + success_url = "horizon:project:windc:index" + default_steps = (SelectProjectUser, + SetInstanceDetails, + SetAccessControls, + SetNetwork, + VolumeOptions, + PostCreationStep) + + def format_status_message(self, message): + name = self.context.get('name', 'unknown instance') + count = self.context.get('count', 1) + if int(count) > 1: + return message % {"count": _("%s instances") % count, + "name": name} + else: + return message % {"count": _("instance"), "name": name} + + def handle(self, request, context): + custom_script = context.get('customization_script', '') + + # Determine volume mapping options + if context.get('volume_type', None): + if(context['delete_on_terminate']): + del_on_terminate = 1 + else: + del_on_terminate = 0 + mapping_opts = ("%s::%s" + % (context['volume_id'], del_on_terminate)) + dev_mapping = {context['device_name']: mapping_opts} + else: + dev_mapping = None + + netids = context.get('network_id', None) + if netids: + nics = [{"net-id": netid, "v4-fixed-ip": ""} + for netid in netids] + else: + nics = None + + try: + api.nova.server_create(request, + context['name'], + context['source_id'], + context['flavor'], + context['keypair_id'], + normalize_newlines(custom_script), + context['security_group_ids'], + dev_mapping, + nics=nics, + instance_count=int(context['count'])) + return True + except: + exceptions.handle(request) + return False