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
This commit is contained in:
hyphon-zh 2013-06-04 14:49:40 +08:00
parent c5f968afee
commit 8770b32dfa
8 changed files with 342 additions and 3 deletions
openstack_dashboard

@ -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)

@ -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)

@ -0,0 +1,47 @@
{% load i18n horizon humanize %}
<h4>{% trans "Flavor Details" %}</h4>
<table class="flavor_table table-striped">
<tbody>
<tr><td class="flavor_name">{% trans "Name" %}</td><td><span id="flavor_name"></span></td></tr>
<tr><td class="flavor_name">{% trans "VCPUs" %}</td><td><span id="flavor_vcpus"></span></td></tr>
<tr><td class="flavor_name">{% trans "Root Disk" %}</td><td><span id="flavor_disk"> </span> {% trans "GB" %}</td></tr>
<tr><td class="flavor_name">{% trans "Ephemeral Disk" %}</td><td><span id="flavor_ephemeral"></span> {% trans "GB" %}</td></tr>
<tr><td class="flavor_name">{% trans "Total Disk" %}</td><td><span id="flavor_disk_total"></span> {% trans "GB" %}</td></tr>
<tr><td class="flavor_name">{% trans "RAM" %}</td><td><span id="flavor_ram"></span> {% trans "MB" %}</td></tr>
</tbody>
</table>
<div class="quota-dynamic">
<h4>{% trans "Project Quotas" %}</h4>
<div class="quota_title clearfix">
<strong>{% trans "Number of Instances" %} <span>({{ usages.instances.used|intcomma }})</span></strong>
<p>{{ usages.instances.available|quota|intcomma }}</p>
</div>
<div id="quota_instances" class="quota_bar" data-progress-indicator-flavor data-quota-limit="{{ usages.instances.quota }}" data-quota-used="{{ usages.instances.used }}">
</div>
<div class="quota_title clearfix">
<strong>{% trans "Number of VCPUs" %} <span>({{ usages.cores.used|intcomma }})</span></strong>
<p>{{ usages.cores.available|quota|intcomma }}</p>
</div>
<div id="quota_vcpus" class="quota_bar" data-progress-indicator-flavor data-quota-limit="{{ usages.cores.quota }}" data-quota-used="{{ usages.cores.used }}">
</div>
<div class="quota_title clearfix">
<strong>{% trans "Total RAM" %} <span>({{ usages.ram.used|intcomma }} {% trans "MB" %})</span></strong>
<p>{{ usages.ram.available|quota:_("MB")|intcomma }}</p>
</div>
<div id="quota_ram" data-progress-indicator-flavor data-quota-limit="{{ usages.ram.quota }}" data-quota-used="{{ usages.ram.used }}" class="quota_bar">
</div>
</div>
<script type="text/javascript" charset="utf-8">
if(typeof horizon.Quota !== 'undefined') {
horizon.Quota.initWithFlavors({{ flavors|safe|default:"{}" }});
} else {
addHorizonLoadEvent(function() {
horizon.Quota.initWithFlavors({{ flavors|safe|default:"{}" }});
});
}
</script>

@ -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)

@ -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<instance_id>[^/]+)/%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'),
)

@ -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

@ -1,2 +1,3 @@
from create_instance import *
from update_instance import *
from resize_instance import *

@ -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