diff --git a/openstack_dashboard/api/nova.py b/openstack_dashboard/api/nova.py index 437cc0431..190694416 100644 --- a/openstack_dashboard/api/nova.py +++ b/openstack_dashboard/api/nova.py @@ -463,6 +463,10 @@ def server_migrate(request, instance_id): novaclient(request).servers.migrate(instance_id) +def server_resize(request, instance_id, flavor, **kwargs): + novaclient(request).servers.resize(instance_id, flavor, **kwargs) + + def server_confirm_resize(request, instance_id): novaclient(request).servers.confirm_resize(instance_id) diff --git a/openstack_dashboard/dashboards/project/instances/tables.py b/openstack_dashboard/dashboards/project/instances/tables.py index ef46ad911..82f8ae998 100644 --- a/openstack_dashboard/dashboards/project/instances/tables.py +++ b/openstack_dashboard/dashboards/project/instances/tables.py @@ -272,6 +272,26 @@ class LogLink(tables.LinkAction): return "?".join([base_url, tab_query_string]) +class ResizeLink(tables.LinkAction): + name = "resize" + verbose_name = _("Resize Instance") + url = "horizon:project:instances:resize" + classes = ("ajax-modal", "btn-resize") + + def get_link_url(self, project): + return self._get_link_url(project, 'flavor_choice') + + def _get_link_url(self, project, step_slug): + base_url = urlresolvers.reverse(self.url, args=[project.id]) + param = urlencode({"step": step_slug}) + return "?".join([base_url, param]) + + def allowed(self, request, instance): + return ((instance.status in ACTIVE_STATES + or instance.status == 'SHUTOFF') + and not is_deleting(instance)) + + class ConfirmResize(tables.Action): name = "confirm" verbose_name = _("Confirm Resize/Migrate") @@ -498,5 +518,5 @@ class InstancesTable(tables.DataTable): SimpleAssociateIP, AssociateIP, SimpleDisassociateIP, EditInstance, EditInstanceSecurityGroups, ConsoleLink, LogLink, - TogglePause, ToggleSuspend, SoftRebootInstance, - RebootInstance, TerminateInstance) + TogglePause, ToggleSuspend, ResizeLink, + SoftRebootInstance, RebootInstance, TerminateInstance) diff --git a/openstack_dashboard/dashboards/project/instances/templates/instances/_resize_instance_help.html b/openstack_dashboard/dashboards/project/instances/templates/instances/_resize_instance_help.html new file mode 100644 index 000000000..089265360 --- /dev/null +++ b/openstack_dashboard/dashboards/project/instances/templates/instances/_resize_instance_help.html @@ -0,0 +1,47 @@ +{% load i18n horizon humanize %} + +

{% trans "Flavor Details" %}

+ + + + + + + + + +
{% trans "Name" %}
{% trans "VCPUs" %}
{% trans "Root Disk" %} {% trans "GB" %}
{% trans "Ephemeral Disk" %} {% trans "GB" %}
{% trans "Total Disk" %} {% trans "GB" %}
{% trans "RAM" %} {% trans "MB" %}
+ +
+

{% trans "Project Quotas" %}

+
+ {% trans "Number of Instances" %} ({{ usages.instances.used|intcomma }}) +

{{ usages.instances.available|quota|intcomma }}

+
+
+
+ +
+ {% trans "Number of VCPUs" %} ({{ usages.cores.used|intcomma }}) +

{{ usages.cores.available|quota|intcomma }}

+
+
+
+ +
+ {% trans "Total RAM" %} ({{ usages.ram.used|intcomma }} {% trans "MB" %}) +

{{ usages.ram.available|quota:_("MB")|intcomma }}

+
+
+
+
+ + diff --git a/openstack_dashboard/dashboards/project/instances/tests.py b/openstack_dashboard/dashboards/project/instances/tests.py index 216f87764..9a8faf4c5 100644 --- a/openstack_dashboard/dashboards/project/instances/tests.py +++ b/openstack_dashboard/dashboards/project/instances/tests.py @@ -1625,3 +1625,105 @@ class InstanceTests(test.TestCase): res = self.client.post(INDEX_URL, formData) self.assertRedirectsNoFollow(res, INDEX_URL) + + @test.create_stubs({api.nova: ('server_get', + 'flavor_list',), + quotas: ('tenant_quota_usages',)}) + def test_instance_resize_get(self): + server = self.servers.first() + + api.nova.server_get(IsA(http.HttpRequest), server.id) \ + .AndReturn(server) + api.nova.flavor_list(IsA(http.HttpRequest)) \ + .AndReturn(self.flavors.list()) + api.nova.flavor_list(IsA(http.HttpRequest)) \ + .AndReturn(self.flavors.list()) + quotas.tenant_quota_usages(IsA(http.HttpRequest)) \ + .AndReturn(self.quota_usages.first()) + + self.mox.ReplayAll() + + url = reverse('horizon:project:instances:resize', args=[server.id]) + res = self.client.get(url) + + self.assertTemplateUsed(res, WorkflowView.template_name) + + @test.create_stubs({api.nova: ('server_get', + 'flavor_list',)}) + def test_instance_resize_get_server_get_exception(self): + server = self.servers.first() + + api.nova.server_get(IsA(http.HttpRequest), server.id) \ + .AndRaise(self.exceptions.nova) + + self.mox.ReplayAll() + + url = reverse('horizon:project:instances:resize', + args=[server.id]) + res = self.client.get(url) + + self.assertRedirectsNoFollow(res, INDEX_URL) + + @test.create_stubs({api.nova: ('server_get', + 'flavor_list',)}) + def test_instance_resize_get_flavor_list_exception(self): + server = self.servers.first() + + api.nova.server_get(IsA(http.HttpRequest), server.id) \ + .AndReturn(server) + api.nova.flavor_list(IsA(http.HttpRequest)) \ + .AndRaise(self.exceptions.nova) + + self.mox.ReplayAll() + + url = reverse('horizon:project:instances:resize', + args=[server.id]) + res = self.client.get(url) + + self.assertRedirectsNoFollow(res, INDEX_URL) + + def _instance_resize_post(self, server_id, flavor_id): + formData = {'flavor': flavor_id, + 'default_role': 'member'} + url = reverse('horizon:project:instances:resize', + args=[server_id]) + return self.client.post(url, formData) + + instance_resize_post_stubs = { + api.nova: ('server_get', 'server_resize', + 'flavor_list', 'flavor_get')} + + @test.create_stubs(instance_resize_post_stubs) + def test_instance_resize_post(self): + server = self.servers.first() + flavor = self.flavors.first() + + api.nova.server_get(IsA(http.HttpRequest), server.id) \ + .AndReturn(server) + api.nova.flavor_list(IsA(http.HttpRequest)) \ + .AndReturn(self.flavors.list()) + api.nova.server_resize(IsA(http.HttpRequest), server.id, flavor.id) \ + .AndReturn([]) + + self.mox.ReplayAll() + + res = self._instance_resize_post(server.id, flavor.id) + self.assertNoFormErrors(res) + self.assertRedirectsNoFollow(res, INDEX_URL) + + @test.create_stubs(instance_resize_post_stubs) + def test_instance_resize_post_api_exception(self): + server = self.servers.first() + flavor = self.flavors.first() + + api.nova.server_get(IsA(http.HttpRequest), server.id) \ + .AndReturn(server) + api.nova.flavor_list(IsA(http.HttpRequest)) \ + .AndReturn(self.flavors.list()) + api.nova.server_resize(IsA(http.HttpRequest), server.id, flavor.id) \ + .AndRaise(self.exceptions.nova) + + self.mox.ReplayAll() + + res = self._instance_resize_post(server.id, flavor.id) + self.assertRedirectsNoFollow(res, INDEX_URL) diff --git a/openstack_dashboard/dashboards/project/instances/urls.py b/openstack_dashboard/dashboards/project/instances/urls.py index 461c04c44..dfccde337 100644 --- a/openstack_dashboard/dashboards/project/instances/urls.py +++ b/openstack_dashboard/dashboards/project/instances/urls.py @@ -21,6 +21,7 @@ from django.conf.urls.defaults import patterns, url from .views import IndexView, UpdateView, DetailView, LaunchInstanceView +from .views import ResizeView INSTANCES = r'^(?P[^/]+)/%s$' @@ -35,4 +36,5 @@ urlpatterns = patterns(VIEW_MOD, url(INSTANCES % 'console', 'console', name='console'), url(INSTANCES % 'vnc', 'vnc', name='vnc'), url(INSTANCES % 'spice', 'spice', name='spice'), + url(INSTANCES % 'resize', ResizeView.as_view(), name='resize'), ) diff --git a/openstack_dashboard/dashboards/project/instances/views.py b/openstack_dashboard/dashboards/project/instances/views.py index 517bf3dfe..32809faf5 100644 --- a/openstack_dashboard/dashboards/project/instances/views.py +++ b/openstack_dashboard/dashboards/project/instances/views.py @@ -38,7 +38,7 @@ from horizon import workflows from openstack_dashboard import api from .tabs import InstanceDetailTabs from .tables import InstancesTable -from .workflows import LaunchInstance, UpdateInstance +from .workflows import LaunchInstance, UpdateInstance, ResizeInstance LOG = logging.getLogger(__name__) @@ -203,3 +203,53 @@ class DetailView(tabs.TabView): def get_tabs(self, request, *args, **kwargs): instance = self.get_data() return self.tab_group_class(request, instance=instance, **kwargs) + + +class ResizeView(workflows.WorkflowView): + workflow_class = ResizeInstance + success_url = reverse_lazy("horizon:project:instances:index") + + def get_context_data(self, **kwargs): + context = super(ResizeView, self).get_context_data(**kwargs) + context["instance_id"] = self.kwargs['instance_id'] + return context + + def get_object(self, *args, **kwargs): + if not hasattr(self, "_object"): + instance_id = self.kwargs['instance_id'] + try: + self._object = api.nova.server_get(self.request, instance_id) + flavor_id = self._object.flavor['id'] + flavors = self.get_flavors() + if flavor_id in flavors: + self._object.flavor_name = flavors[flavor_id].name + else: + flavor = api.nova.flavor_get(self.request, flavor_id) + self._object.flavor_name = flavor.name + except: + redirect = reverse("horizon:project:instances:index") + msg = _('Unable to retrieve instance details.') + exceptions.handle(self.request, msg, redirect=redirect) + return self._object + + def get_flavors(self, *args, **kwargs): + if not hasattr(self, "_flavors"): + try: + flavors = api.nova.flavor_list(self.request) + self._flavors = SortedDict([(str(flavor.id), flavor) + for flavor in flavors]) + except: + redirect = reverse("horizon:project:instances:index") + exceptions.handle(self.request, + _('Unable to retrieve flavors.'), redirect=redirect) + return self._flavors + + def get_initial(self): + initial = super(ResizeView, self).get_initial() + _object = self.get_object() + if _object: + initial.update({'instance_id': self.kwargs['instance_id'], + 'old_flavor_id': _object.flavor['id'], + 'old_flavor_name': getattr(_object, 'flavor_name', ''), + 'flavors': self.get_flavors()}) + return initial diff --git a/openstack_dashboard/dashboards/project/instances/workflows/__init__.py b/openstack_dashboard/dashboards/project/instances/workflows/__init__.py index d3823fc9c..1525d9646 100644 --- a/openstack_dashboard/dashboards/project/instances/workflows/__init__.py +++ b/openstack_dashboard/dashboards/project/instances/workflows/__init__.py @@ -1,2 +1,3 @@ from create_instance import * from update_instance import * +from resize_instance import * diff --git a/openstack_dashboard/dashboards/project/instances/workflows/resize_instance.py b/openstack_dashboard/dashboards/project/instances/workflows/resize_instance.py new file mode 100644 index 000000000..b6a07c1d2 --- /dev/null +++ b/openstack_dashboard/dashboards/project/instances/workflows/resize_instance.py @@ -0,0 +1,113 @@ +# vim: tabstop=4 shiftwidth=4 softtabstop=4 + +# Copyright 2013 CentRin Data, 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 +import json + +from django.utils.translation import ugettext_lazy as _ +from django.utils.datastructures import SortedDict +from django.views.decorators.debug import sensitive_variables + +from horizon import exceptions +from horizon import workflows +from horizon import forms + +from openstack_dashboard import api +from openstack_dashboard.usage import quotas + + +LOG = logging.getLogger(__name__) + + +class SetFlavorChoiceAction(workflows.Action): + old_flavor_id = forms.CharField(required=False, widget=forms.HiddenInput()) + old_flavor_name = forms.CharField(label=_("Old Flavor"), + required=False, + widget=forms.TextInput( + attrs={'readonly': 'readonly'} + )) + flavor = forms.ChoiceField(label=_("New Flavor"), + required=True, + help_text=_("Choose the flavor to launch.")) + + class Meta: + name = _("Flavor Choice") + slug = 'flavor_choice' + help_text_template = ("project/instances/" + "_resize_instance_help.html") + + def clean(self): + cleaned_data = super(SetFlavorChoiceAction, self).clean() + flavor = cleaned_data.get('flavor', None) + + if flavor is None or flavor == cleaned_data['old_flavor_id']: + raise forms.ValidationError(_('Please choose a new flavor that ' + 'can not be same as the old one.')) + return cleaned_data + + def populate_flavor_choices(self, request, context): + flavors = context.get('flavors') + flavor_list = [(flavor.id, '%s' % flavor.name) + for flavor in flavors.values()] + if flavor_list: + flavor_list.insert(0, ("", _("Select an New Flavor"))) + else: + flavor_list.insert(0, ("", _("No flavors available."))) + 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(SetFlavorChoiceAction, self).get_help_text(extra) + + +class SetFlavorChoice(workflows.Step): + action_class = SetFlavorChoiceAction + depends_on = ("instance_id",) + contributes = ("old_flavor_id", "old_flavor_name", "flavors", "flavor") + + +class ResizeInstance(workflows.Workflow): + slug = "resize_instance" + name = _("Resize Instance") + finalize_button_name = _("Resize") + success_message = _('Resized instance "%s".') + failure_message = _('Unable to resize instance "%s".') + success_url = "horizon:project:instances:index" + default_steps = (SetFlavorChoice,) + + def format_status_message(self, message): + return message % self.context.get('name', 'unknown instance') + + @sensitive_variables('context') + def handle(self, request, context): + instance_id = context.get('instance_id', None) + flavor = context.get('flavor', None) + try: + api.nova.server_resize(request, instance_id, flavor) + return True + except: + exceptions.handle(request) + return False