From 8770b32dfa89f1fcf74368fa73ff0aba2ba7dc9d Mon Sep 17 00:00:00 2001 From: hyphon-zh Date: Tue, 4 Jun 2013 14:49:40 +0800 Subject: [PATCH] Resizing a server by means of changing its flavor Sometime we need resize the server when it is launched, such as the number of vcpu, the size of memory and the size of disk. We can achieve this by means of changing its flavor. Change-Id: I1ab6b61f286e951b644a2e66383ac62c6a6f887e Implements: blueprint resize-server --- openstack_dashboard/api/nova.py | 4 + .../dashboards/project/instances/tables.py | 24 +++- .../instances/_resize_instance_help.html | 47 ++++++++ .../dashboards/project/instances/tests.py | 102 ++++++++++++++++ .../dashboards/project/instances/urls.py | 2 + .../dashboards/project/instances/views.py | 52 +++++++- .../project/instances/workflows/__init__.py | 1 + .../instances/workflows/resize_instance.py | 113 ++++++++++++++++++ 8 files changed, 342 insertions(+), 3 deletions(-) create mode 100644 openstack_dashboard/dashboards/project/instances/templates/instances/_resize_instance_help.html create mode 100644 openstack_dashboard/dashboards/project/instances/workflows/resize_instance.py diff --git a/openstack_dashboard/api/nova.py b/openstack_dashboard/api/nova.py index bbb2bc0f57..8fe83a10d3 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 ef46ad9110..82f8ae9986 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 0000000000..0892653603 --- /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 ee3888ee1b..5580eb7cf0 100644 --- a/openstack_dashboard/dashboards/project/instances/tests.py +++ b/openstack_dashboard/dashboards/project/instances/tests.py @@ -1578,3 +1578,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 461c04c448..dfccde337f 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 517bf3dfeb..32809faf5f 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 d3823fc9c1..1525d96462 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 0000000000..b6a07c1d2c --- /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