Trove add cluster grow and shrink support

Add support for Trove commands cluster-grow and cluster-shrink.

Added the grow and shrink actions to the clusters list table.  The
grow and shrink actions are only available for MongoDB and Redis
clusters.

Added the grow panel table to list the new instances to be added to
the cluster.  There is a table action Add Instance where the instance
details are specified then added to the new instances table.
A Remove Instance table and row action is available to remove any
instances from the table.  A Grow Cluster table action will add the
instances to the instances in the table to the cluster.  Removed the
add shard action as it is now deprecated and is replaced by the
grow action.

Added a cluster manager helper to keep track of the newly added
instances in the grow panel.

Added the shrink panel table that lists the instances belonging to
the cluster in a table.  The selected instance(s) can then be
removed from the cluster with the shrink command.

Added the cluster_grow and cluster_shrink commands to the api.

Change-Id: I05dbc73282b333e3ed8cfd4cdbda673ec86f57fd
Co-Authored-By: Duk Loi <duk@tesora.com>
Implements: blueprint trove-support-cluster-grow-shrink
This commit is contained in:
Matt Van Dijk 2016-01-20 14:18:22 -05:00 committed by Duk Loi
parent 6a7d58193c
commit 3ea4a7ca62
15 changed files with 733 additions and 116 deletions

View File

@ -76,8 +76,25 @@ def cluster_create(request, name, volume, flavor, num_instances,
instances=instances) instances=instances)
def cluster_add_shard(request, cluster_id): def cluster_grow(request, cluster_id, new_instances):
return troveclient(request).clusters.add_shard(cluster_id) instances = []
for new_instance in new_instances:
instance = {}
instance["flavorRef"] = new_instance.flavor_id
if new_instance.volume > 0:
instance["volume"] = {'size': new_instance.volume}
if new_instance.name:
instance["name"] = new_instance.name
if new_instance.type:
instance["type"] = new_instance.type
if new_instance.related_to:
instance["related_to"] = new_instance.related_to
instances.append(instance)
return troveclient(request).clusters.grow(cluster_id, instances)
def cluster_shrink(request, cluster_id, instances):
return troveclient(request).clusters.shrink(cluster_id, instances)
def create_cluster_root(request, cluster_id, password): def create_cluster_root(request, cluster_id, password):

View File

@ -0,0 +1,86 @@
# Copyright 2016 Tesora 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.core import cache
def get(cluster_id):
if not has_cluster(cluster_id):
manager = ClusterInstanceManager(cluster_id)
cache.cache.set(cluster_id, manager)
return cache.cache.get(cluster_id)
def delete(cluster_id):
manager = get(cluster_id)
manager.clear_instances()
cache.cache.delete(cluster_id)
def update(cluster_id, manager):
cache.cache.set(cluster_id, manager)
def has_cluster(cluster_id):
if cache.cache.get(cluster_id):
return True
else:
return False
class ClusterInstanceManager(object):
instances = []
def __init__(self, cluster_id):
self.cluster_id = cluster_id
def get_instances(self):
return self.instances
def get_instance(self, id):
for instance in self.instances:
if instance.id == id:
return instance
return None
def add_instance(self, id, name, flavor_id,
flavor, volume, type, related_to):
instance = ClusterInstance(id, name, flavor_id, flavor,
volume, type, related_to)
self.instances.append(instance)
update(self.cluster_id, self)
return self.instances
def delete_instance(self, id):
instance = self.get_instance(id)
if instance:
self.instances.remove(instance)
update(self.cluster_id, self)
def clear_instances(self):
del self.instances[:]
class ClusterInstance(object):
def __init__(self, id, name, flavor_id, flavor, volume, type, related_to):
self.id = id
self.name = name
self.flavor_id = flavor_id
self.flavor = flavor
self.volume = volume
self.type = type
self.related_to = related_to

View File

@ -14,6 +14,7 @@
# under the License. # under the License.
import logging import logging
import uuid
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
@ -26,6 +27,8 @@ from horizon.utils import memoized
from openstack_dashboard import api from openstack_dashboard import api
from trove_dashboard import api as trove_api from trove_dashboard import api as trove_api
from trove_dashboard.content.database_clusters \
import cluster_manager
from trove_dashboard.content.databases import db_capability from trove_dashboard.content.databases import db_capability
LOG = logging.getLogger(__name__) LOG = logging.getLogger(__name__)
@ -314,39 +317,78 @@ class LaunchForm(forms.SelfHandlingForm):
redirect=redirect) redirect=redirect)
class AddShardForm(forms.SelfHandlingForm): class ClusterAddInstanceForm(forms.SelfHandlingForm):
name = forms.CharField( cluster_id = forms.CharField(
label=_("Cluster Name"), required=False,
max_length=80,
widget=forms.TextInput(attrs={'readonly': 'readonly'}))
num_shards = forms.IntegerField(
label=_("Number of Shards"),
initial=1,
widget=forms.TextInput(attrs={'readonly': 'readonly'}))
num_instances = forms.IntegerField(label=_("Instances Per Shard"),
initial=3,
widget=forms.TextInput(
attrs={'readonly': 'readonly'}))
cluster_id = forms.CharField(required=False,
widget=forms.HiddenInput()) widget=forms.HiddenInput())
flavor = forms.ChoiceField(
label=_("Flavor"),
help_text=_("Size of image to launch."))
volume = forms.IntegerField(
label=_("Volume Size"),
min_value=0,
initial=1,
help_text=_("Size of the volume in GB."))
name = forms.CharField(
label=_("Name"),
required=False,
help_text=_("Optional name of the instance."))
type = forms.CharField(
label=_("Instance Type"),
required=False,
help_text=_("Optional datastore specific type of the instance."))
related_to = forms.CharField(
label=_("Related To"),
required=False,
help_text=_("Optional datastore specific value that defines the "
"relationship from one instance in the cluster to "
"another."))
def __init__(self, request, *args, **kwargs):
super(ClusterAddInstanceForm, self).__init__(request, *args, **kwargs)
self.fields['flavor'].choices = self.populate_flavor_choices(request)
@memoized.memoized_method
def flavors(self, request):
try:
datastore = None
datastore_version = None
datastore_dict = self.initial.get('datastore', None)
if datastore_dict:
datastore = datastore_dict.get('type', None)
datastore_version = datastore_dict.get('version', None)
return trove_api.trove.datastore_flavors(
request,
datastore_name=datastore,
datastore_version=datastore_version)
except Exception:
LOG.exception("Exception while obtaining flavors list")
self._flavors = []
redirect = reverse('horizon:project:database_clusters:index')
exceptions.handle(request,
_('Unable to obtain flavors.'),
redirect=redirect)
def populate_flavor_choices(self, request):
flavor_list = [(f.id, "%s" % f.name) for f in self.flavors(request)]
return sorted(flavor_list)
def handle(self, request, data): def handle(self, request, data):
try: try:
LOG.info("Adding shard with parameters " flavor = trove_api.trove.flavor_get(request, data['flavor'])
"{name=%s, num_shards=%s, num_instances=%s, " manager = cluster_manager.get(data['cluster_id'])
"cluster_id=%s}", manager.add_instance(str(uuid.uuid4()),
data['name'], data.get('name', None),
data['num_shards'], data['flavor'],
data['num_instances'], flavor.name,
data['cluster_id']) data['volume'],
trove_api.trove.cluster_add_shard(request, data['cluster_id']) data.get('type', None),
data.get('related_to', None))
messages.success(request,
_('Added shard to "%s"') % data['name'])
except Exception as e: except Exception as e:
redirect = reverse("horizon:project:database_clusters:index") redirect = reverse("horizon:project:database_clusters:index")
exceptions.handle(request, exceptions.handle(request,
_('Unable to add shard. %s') % e.message, _('Unable to grow cluster. %s') % e.message,
redirect=redirect) redirect=redirect)
return True return True

View File

@ -14,19 +14,27 @@
# License for the specific language governing permissions and limitations # License for the specific language governing permissions and limitations
# under the License. # under the License.
import logging
from django.core import urlresolvers from django.core import urlresolvers
from django import shortcuts
from django.template.defaultfilters import title # noqa from django.template.defaultfilters import title # noqa
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django.utils.translation import ungettext_lazy from django.utils.translation import ungettext_lazy
from horizon import messages
from horizon import tables from horizon import tables
from horizon.templatetags import sizeformat from horizon.templatetags import sizeformat
from horizon.utils import filters from horizon.utils import filters
from horizon.utils import functions
from horizon.utils import memoized from horizon.utils import memoized
from trove_dashboard import api from trove_dashboard import api
from trove_dashboard.content.database_clusters import cluster_manager
from trove_dashboard.content.databases import db_capability from trove_dashboard.content.databases import db_capability
LOG = logging.getLogger(__name__)
ACTIVE_STATES = ("ACTIVE",) ACTIVE_STATES = ("ACTIVE",)
@ -64,16 +72,29 @@ class LaunchLink(tables.LinkAction):
icon = "cloud-upload" icon = "cloud-upload"
class AddShard(tables.LinkAction): class ClusterGrow(tables.LinkAction):
name = "add_shard" name = "cluster_grow"
verbose_name = _("Add Shard") verbose_name = _("Grow Cluster")
url = "horizon:project:database_clusters:add_shard" url = "horizon:project:database_clusters:cluster_grow_details"
classes = ("ajax-modal",)
icon = "plus" icon = "plus"
def allowed(self, request, cluster=None): def allowed(self, request, cluster=None):
if (cluster and cluster.task["name"] == 'NONE' and if (cluster and cluster.task["name"] == 'NONE' and
db_capability.is_mongodb_datastore(cluster.datastore['type'])): db_capability.can_modify_cluster(cluster.datastore['type'])):
return True
return False
class ClusterShrink(tables.LinkAction):
name = "cluster_shrink"
verbose_name = _("Shrink Cluster")
url = "horizon:project:database_clusters:cluster_shrink_details"
classes = ("btn-danger",)
icon = "remove"
def allowed(self, request, cluster=None):
if (cluster and cluster.task["name"] == 'NONE' and
db_capability.can_modify_cluster(cluster.datastore['type'])):
return True return True
return False return False
@ -163,7 +184,8 @@ class ClustersTable(tables.DataTable):
status_columns = ["task"] status_columns = ["task"]
row_class = UpdateRow row_class = UpdateRow
table_actions = (LaunchLink, DeleteCluster) table_actions = (LaunchLink, DeleteCluster)
row_actions = (AddShard, ResetPassword, DeleteCluster) row_actions = (ClusterGrow, ClusterShrink, ResetPassword,
DeleteCluster)
def get_instance_size(instance): def get_instance_size(instance):
@ -206,3 +228,208 @@ class InstancesTable(tables.DataTable):
class Meta(object): class Meta(object):
name = "instances" name = "instances"
verbose_name = _("Instances") verbose_name = _("Instances")
class ClusterShrinkAction(tables.BatchAction):
name = "cluster_shrink_action"
icon = "remove"
classes = ('btn-danger',)
success_url = 'horizon:project:database_clusters:index'
help_text = _("Shrinking a cluster is not recoverable.")
@staticmethod
def action_present(count):
return ungettext_lazy(
u"Shrink Cluster",
u"Shrink Cluster",
count
)
@staticmethod
def action_past(count):
return ungettext_lazy(
u"Scheduled Shrinking of Cluster",
u"Scheduled Shrinking of Cluster",
count
)
def handle(self, table, request, obj_ids):
datum_display_objs = []
for datum_id in obj_ids:
datum = table.get_object_by_id(datum_id)
datum_display = table.get_object_display(datum) or datum_id
datum_display_objs.append(datum_display)
display_str = functions.lazy_join(", ", datum_display_objs)
try:
cluster_id = table.kwargs['cluster_id']
data = [{'id': instance_id} for instance_id in obj_ids]
api.trove.cluster_shrink(request, cluster_id, data)
LOG.info('%s: "%s"' %
(self._get_action_name(past=True),
display_str))
msg = _('Removed instances from cluster.')
messages.info(request, msg)
except Exception as ex:
LOG.error('Action %(action)s failed with %(ex)s for %(data)s' %
{'action': self._get_action_name(past=True).lower(),
'ex': ex.message,
'data': display_str})
msg = _('Unable to remove instances from cluster: %s')
messages.error(request, msg % ex.message)
return shortcuts.redirect(self.get_success_url(request))
class ClusterShrinkInstancesTable(tables.DataTable):
name = tables.Column("name",
verbose_name=_("Name"))
status = tables.Column("status",
filters=(title, filters.replace_underscores),
verbose_name=_("Status"))
class Meta(object):
name = "shrink_cluster_table"
verbose_name = _("Instances")
table_actions = (ClusterShrinkAction,)
row_actions = (ClusterShrinkAction,)
class ClusterGrowAddInstance(tables.LinkAction):
name = "cluster_grow_add_instance"
verbose_name = _("Add Instance")
url = "horizon:project:database_clusters:add_instance"
classes = ("ajax-modal",)
def get_link_url(self):
return urlresolvers.reverse(
self.url, args=[self.table.kwargs['cluster_id']])
class ClusterGrowRemoveInstance(tables.BatchAction):
name = "cluster_grow_remove_instance"
@staticmethod
def action_present(count):
return ungettext_lazy(
u"Remove Instance",
u"Remove Instances",
count
)
@staticmethod
def action_past(count):
return ungettext_lazy(
u"Removed Instance",
u"Removed Instances",
count
)
def action(self, request, datum_id):
manager = cluster_manager.get(self.table.kwargs['cluster_id'])
manager.delete_instance(datum_id)
def handle(self, table, request, obj_ids):
action_success = []
action_failure = []
action_not_allowed = []
for datum_id in obj_ids:
datum = table.get_object_by_id(datum_id)
datum_display = table.get_object_display(datum) or datum_id
if not table._filter_action(self, request, datum):
action_not_allowed.append(datum_display)
LOG.warning('Permission denied to %s: "%s"' %
(self._get_action_name(past=True).lower(),
datum_display))
continue
try:
self.action(request, datum_id)
# Call update to invoke changes if needed
self.update(request, datum)
action_success.append(datum_display)
self.success_ids.append(datum_id)
LOG.info('%s: "%s"' %
(self._get_action_name(past=True), datum_display))
except Exception as ex:
# Handle the exception but silence it since we'll display
# an aggregate error message later. Otherwise we'd get
# multiple error messages displayed to the user.
action_failure.append(datum_display)
action_description = (
self._get_action_name(past=True).lower(), datum_display)
LOG.error(
'Action %(action)s Failed for %(reason)s', {
'action': action_description, 'reason': ex})
if action_not_allowed:
msg = _('You are not allowed to %(action)s: %(objs)s')
params = {"action":
self._get_action_name(action_not_allowed).lower(),
"objs": functions.lazy_join(", ", action_not_allowed)}
messages.error(request, msg % params)
if action_failure:
msg = _('Unable to %(action)s: %(objs)s')
params = {"action": self._get_action_name(action_failure).lower(),
"objs": functions.lazy_join(", ", action_failure)}
messages.error(request, msg % params)
return shortcuts.redirect(self.get_success_url(request))
class ClusterGrowAction(tables.Action):
name = "grow_cluster_action"
verbose_name = _("Grow Cluster")
verbose_name_plural = _("Grow Cluster")
requires_input = False
icon = "plus"
def handle(self, table, request, obj_ids):
if not table.data:
msg = _("Cannot grow cluster. No instances specified.")
messages.info(request, msg)
return shortcuts.redirect(request.build_absolute_uri())
datum_display_objs = []
for instance in table.data:
msg = _("[flavor=%(flavor)s, volume=%(volume)s, name=%(name)s, "
"type=%(type)s, related_to=%(related_to)s]")
params = {"flavor": instance.flavor_id, "volume": instance.volume,
"name": instance.name, "type": instance.type,
"related_to": instance.related_to}
datum_display_objs.append(msg % params)
display_str = functions.lazy_join(", ", datum_display_objs)
cluster_id = table.kwargs['cluster_id']
try:
api.trove.cluster_grow(request, cluster_id, table.data)
LOG.info('%s: "%s"' % (_("Grow Cluster"), display_str))
msg = _('Scheduled growing of cluster.')
messages.success(request, msg)
except Exception as ex:
LOG.error('Action grow cluster failed with %(ex)s for %(data)s' %
{'ex': ex.message,
'data': display_str})
msg = _('Unable to grow cluster: %s')
messages.error(request, msg % ex.message)
finally:
cluster_manager.delete(cluster_id)
return shortcuts.redirect(urlresolvers.reverse(
"horizon:project:database_clusters:index"))
class ClusterGrowInstancesTable(tables.DataTable):
id = tables.Column("id", hidden=True)
name = tables.Column("name", verbose_name=_("Name"))
flavor = tables.Column("flavor", verbose_name=_("Flavor"))
flavor_id = tables.Column("flavor_id", hidden=True)
volume = tables.Column("volume", verbose_name=_("Volume"))
type = tables.Column("type", verbose_name=_("Instance Type"))
related_to = tables.Column("related_to", verbose_name=_("Related To"))
class Meta(object):
name = "cluster_grow_instances_table"
verbose_name = _("Instances")
table_actions = (ClusterGrowAddInstance, ClusterGrowRemoveInstance,
ClusterGrowAction)
row_actions = (ClusterGrowRemoveInstance,)

View File

@ -0,0 +1,8 @@
{% extends "horizon/common/_modal_form.html" %}
{% load i18n %}
{% block modal-body-right %}
<p>{% trans "Specify the details of the instance to be added to the cluster." %}</p>
<p>{% trans "The name field is optional. If the field is left blank a name will be generated when the cluster is grown." %}</p>
<p>{% trans "The 'Instance Type' and 'Related To' fields are datastore specific and optional. See the Trove documentation for more information on using these fields." %}</p>
{% endblock %}

View File

@ -1,25 +0,0 @@
{% extends "horizon/common/_modal_form.html" %}
{% load i18n %}
{% load url from future %}
{% block form_id %}add_shard_form{% endblock %}
{% block form_action %}{% url "horizon:project:database_clusters:add_shard" cluster_id %}{% endblock %}
{% block modal_id %}add_shard_modal{% endblock %}
{% block modal-header %}{% trans "Add Shard" %}{% endblock %}
{% block modal-body %}
<div class="left">
<fieldset>
{% include "horizon/common/_form_fields.html" %}
</fieldset>
</div>
<div class="right">
<p>{% blocktrans %}Specify the details for adding additional shards.{% endblocktrans %}</p>
</div>
{% endblock %}
{% block modal-footer %}
<input class="btn btn-primary pull-right" type="submit" value="{% trans "Add Shard" %}" />
<a href="{% url "horizon:project:database_clusters:index" %}" class="btn btn-default secondary cancel close">{% trans "Cancel" %}</a>
{% endblock %}

View File

@ -0,0 +1,5 @@
{% extends "base.html" %}
{% block main %}
{% include "project/database_clusters/_add_instance.html" %}
{% endblock %}

View File

@ -1,7 +0,0 @@
{% extends "base.html" %}
{% load i18n %}
{% block title %}{% trans "Add Shard" %}{% endblock %}
{% block main %}
{% include "project/database_clusters/_add_shard.html" %}
{% endblock %}

View File

@ -0,0 +1,14 @@
{% extends 'base.html' %}
{% load i18n %}
{% block main %}
<hr>
<div class="help_text">
{% trans "Specify the instances to be added to the cluster. When all the instances are specified click 'Grow Cluster' to perform the grow operation." %}
</div>
<div class="row">
<div class="col-sm-12">
{{ table.render }}
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,14 @@
{% extends 'base.html' %}
{% load i18n %}
{% block main %}
<hr>
<div class="help_text">
{% trans "Select the instance(s) that will be removed from the cluster." %}
</div>
<div class="row">
<div class="col-sm-12">
{{ table.render }}
</div>
</div>
{% endblock %}

View File

@ -23,12 +23,14 @@ from openstack_dashboard import api
from troveclient import common from troveclient import common
from trove_dashboard import api as trove_api from trove_dashboard import api as trove_api
from trove_dashboard.content.database_clusters \
import cluster_manager
from trove_dashboard.content.database_clusters import tables
from trove_dashboard.test import helpers as test from trove_dashboard.test import helpers as test
INDEX_URL = reverse('horizon:project:database_clusters:index') INDEX_URL = reverse('horizon:project:database_clusters:index')
LAUNCH_URL = reverse('horizon:project:database_clusters:launch') LAUNCH_URL = reverse('horizon:project:database_clusters:launch')
DETAILS_URL = reverse('horizon:project:database_clusters:detail', args=['id']) DETAILS_URL = reverse('horizon:project:database_clusters:detail', args=['id'])
ADD_SHARD_VIEWNAME = 'horizon:project:database_clusters:add_shard'
RESET_PASSWORD_VIEWNAME = 'horizon:project:database_clusters:reset_password' RESET_PASSWORD_VIEWNAME = 'horizon:project:database_clusters:reset_password'
@ -376,6 +378,179 @@ class ClustersTests(test.TestCase):
self.assertTemplateUsed(res, 'horizon/common/_detail.html') self.assertTemplateUsed(res, 'horizon/common/_detail.html')
self.assertContains(res, cluster.ip[0]) self.assertContains(res, cluster.ip[0])
@test.create_stubs(
{trove_api.trove: ('cluster_get',
'cluster_grow'),
cluster_manager: ('get',)})
def test_grow_cluster(self):
cluster = self.trove_clusters.first()
trove_api.trove.cluster_get(IsA(http.HttpRequest), cluster.id)\
.AndReturn(cluster)
cluster_volume = 1
flavor = self.flavors.first()
cluster_flavor = flavor.id
cluster_flavor_name = flavor.name
instances = [
cluster_manager.ClusterInstance("id1", "name1", cluster_flavor,
cluster_flavor_name,
cluster_volume, "master", None),
cluster_manager.ClusterInstance("id2", "name2", cluster_flavor,
cluster_flavor_name,
cluster_volume, "slave", "master"),
cluster_manager.ClusterInstance("id3", None, cluster_flavor,
cluster_flavor_name,
cluster_volume, None, None),
]
manager = cluster_manager.ClusterInstanceManager(cluster.id)
manager.instances = instances
cluster_manager.get(cluster.id).MultipleTimes().AndReturn(manager)
trove_api.trove.cluster_grow(IsA(http.HttpRequest),
cluster.id,
instances)
self.mox.ReplayAll()
url = reverse('horizon:project:database_clusters:cluster_grow_details',
args=[cluster.id])
res = self.client.get(url)
self.assertTemplateUsed(
res, 'project/database_clusters/cluster_grow_details.html')
table = res.context_data[
"".join([tables.ClusterGrowInstancesTable.Meta.name, '_table'])]
self.assertEqual(len(cluster.instances), len(table.data))
action = "".join([tables.ClusterGrowInstancesTable.Meta.name, '__',
tables.ClusterGrowRemoveInstance.name, '__',
'id1'])
self.client.post(url, {'action': action})
self.assertEqual(len(cluster.instances) - 1, len(table.data))
action = "".join([tables.ClusterGrowInstancesTable.Meta.name, '__',
tables.ClusterGrowAction.name, '__',
cluster.id])
res = self.client.post(url, {'action': action})
self.assertMessageCount(success=1)
self.assertRedirectsNoFollow(res, INDEX_URL)
@test.create_stubs({trove_api.trove: ('cluster_get',)})
def test_grow_cluster_no_instances(self):
cluster = self.trove_clusters.first()
trove_api.trove.cluster_get(IsA(http.HttpRequest), cluster.id)\
.AndReturn(cluster)
self.mox.ReplayAll()
url = reverse('horizon:project:database_clusters:cluster_grow_details',
args=[cluster.id])
res = self.client.get(url)
self.assertTemplateUsed(
res, 'project/database_clusters/cluster_grow_details.html')
action = "".join([tables.ClusterGrowInstancesTable.Meta.name, '__',
tables.ClusterGrowAction.name, '__',
cluster.id])
self.client.post(url, {'action': action})
self.assertMessageCount(info=1)
@test.create_stubs(
{trove_api.trove: ('cluster_get',
'cluster_grow',),
cluster_manager: ('get',)})
def test_grow_cluster_exception(self):
cluster = self.trove_clusters.first()
trove_api.trove.cluster_get(IsA(http.HttpRequest), cluster.id)\
.AndReturn(cluster)
cluster_volume = 1
flavor = self.flavors.first()
cluster_flavor = flavor.id
cluster_flavor_name = flavor.name
instances = [
cluster_manager.ClusterInstance("id1", "name1", cluster_flavor,
cluster_flavor_name,
cluster_volume, "master", None),
cluster_manager.ClusterInstance("id2", "name2", cluster_flavor,
cluster_flavor_name,
cluster_volume, "slave", "master"),
cluster_manager.ClusterInstance("id3", None, cluster_flavor,
cluster_flavor_name,
cluster_volume, None, None),
]
manager = cluster_manager.ClusterInstanceManager(cluster.id)
manager.instances = instances
cluster_manager.get(cluster.id).MultipleTimes().AndReturn(manager)
trove_api.trove.cluster_grow(IsA(http.HttpRequest),
cluster.id,
instances).AndRaise(self.exceptions.trove)
self.mox.ReplayAll()
url = reverse('horizon:project:database_clusters:cluster_grow_details',
args=[cluster.id])
res = self.client.get(url)
self.assertTemplateUsed(
res, 'project/database_clusters/cluster_grow_details.html')
action = "".join([tables.ClusterGrowInstancesTable.Meta.name, '__',
tables.ClusterGrowAction.name, '__',
cluster.id])
res = self.client.post(url, {'action': action})
self.assertMessageCount(error=1)
self.assertRedirectsNoFollow(res, INDEX_URL)
@test.create_stubs({trove_api.trove: ('cluster_get',
'cluster_shrink')})
def test_shrink_cluster(self):
cluster = self.trove_clusters.first()
trove_api.trove.cluster_get(IsA(http.HttpRequest), cluster.id)\
.MultipleTimes().AndReturn(cluster)
instance_id = cluster.instances[0]['id']
cluster_instances = [{'id': instance_id}]
trove_api.trove.cluster_shrink(IsA(http.HttpRequest),
cluster.id,
cluster_instances)
self.mox.ReplayAll()
url = reverse(
'horizon:project:database_clusters:cluster_shrink_details',
args=[cluster.id])
res = self.client.get(url)
self.assertTemplateUsed(
res, 'project/database_clusters/cluster_shrink_details.html')
table = res.context_data[
"".join([tables.ClusterShrinkInstancesTable.Meta.name, '_table'])]
self.assertEqual(len(cluster.instances), len(table.data))
action = "".join([tables.ClusterShrinkInstancesTable.Meta.name, '__',
tables.ClusterShrinkAction.name, '__',
instance_id])
res = self.client.post(url, {'action': action})
self.assertNoFormErrors(res)
self.assertMessageCount(info=1)
self.assertRedirectsNoFollow(res, INDEX_URL)
@test.create_stubs({trove_api.trove: ('cluster_get',
'cluster_shrink')})
def test_shrink_cluster_exception(self):
cluster = self.trove_clusters.first()
trove_api.trove.cluster_get(IsA(http.HttpRequest), cluster.id)\
.MultipleTimes().AndReturn(cluster)
cluster_id = cluster.instances[0]['id']
cluster_instances = [cluster_id]
trove_api.trove.cluster_shrink(IsA(http.HttpRequest),
cluster.id,
cluster_instances)\
.AndRaise(self.exceptions.trove)
self.mox.ReplayAll()
url = reverse(
'horizon:project:database_clusters:cluster_shrink_details',
args=[cluster.id])
action = "".join([tables.ClusterShrinkInstancesTable.Meta.name, '__',
tables.ClusterShrinkAction.name, '__',
cluster_id])
res = self.client.post(url, {'action': action})
self.assertMessageCount(error=1)
self.assertRedirectsNoFollow(res, INDEX_URL)
def _get_filtered_datastores(self, datastore): def _get_filtered_datastores(self, datastore):
filtered_datastore = [] filtered_datastore = []
for ds in self.datastores.list(): for ds in self.datastores.list():

View File

@ -27,8 +27,16 @@ urlpatterns = patterns(
url(r'^launch$', views.LaunchClusterView.as_view(), name='launch'), url(r'^launch$', views.LaunchClusterView.as_view(), name='launch'),
url(r'^(?P<cluster_id>[^/]+)/$', views.DetailView.as_view(), url(r'^(?P<cluster_id>[^/]+)/$', views.DetailView.as_view(),
name='detail'), name='detail'),
url(CLUSTERS % 'add_shard', views.AddShardView.as_view(), url(CLUSTERS % 'cluster_grow_details',
name='add_shard'), views.ClusterGrowView.as_view(),
url(CLUSTERS % 'reset_password', views.ResetPasswordView.as_view(), name='cluster_grow_details'),
url(CLUSTERS % 'add_instance',
views.ClusterAddInstancesView.as_view(),
name='add_instance'),
url(CLUSTERS % 'cluster_shrink_details',
views.ClusterShrinkView.as_view(),
name='cluster_shrink_details'),
url(CLUSTERS % 'reset_password',
views.ResetPasswordView.as_view(),
name='reset_password'), name='reset_password'),
) )

View File

@ -33,6 +33,8 @@ from horizon import tabs as horizon_tabs
from horizon.utils import memoized from horizon.utils import memoized
from trove_dashboard import api from trove_dashboard import api
from trove_dashboard.content.database_clusters \
import cluster_manager
from trove_dashboard.content.database_clusters import forms from trove_dashboard.content.database_clusters import forms
from trove_dashboard.content.database_clusters import tables from trove_dashboard.content.database_clusters import tables
from trove_dashboard.content.database_clusters import tabs from trove_dashboard.content.database_clusters import tabs
@ -137,57 +139,92 @@ class DetailView(horizon_tabs.TabbedTableView):
return self.tab_group_class(request, cluster=cluster, **kwargs) return self.tab_group_class(request, cluster=cluster, **kwargs)
class AddShardView(horizon_forms.ModalFormView): class ClusterGrowView(horizon_tables.DataTableView):
form_class = forms.AddShardForm table_class = tables.ClusterGrowInstancesTable
template_name = 'project/database_clusters/add_shard.html' template_name = 'project/database_clusters/cluster_grow_details.html'
success_url = reverse_lazy('horizon:project:database_clusters:index') page_title = _("Grow Cluster: {{cluster_name}}")
page_title = _("Add Shard")
def get_data(self):
manager = cluster_manager.get(self.kwargs['cluster_id'])
return manager.get_instances()
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
context = super(AddShardView, self).get_context_data(**kwargs) context = super(ClusterGrowView, self).get_context_data(**kwargs)
context["cluster_id"] = self.kwargs['cluster_id'] context['cluster_id'] = self.kwargs['cluster_id']
cluster = self.get_cluster(self.kwargs['cluster_id'])
context['cluster_name'] = cluster.name
return context return context
def get_object(self, *args, **kwargs): @memoized.memoized_method
if not hasattr(self, "_object"): def get_cluster(self, cluster_id):
cluster_id = self.kwargs['cluster_id']
try: try:
self._object = api.trove.cluster_get(self.request, cluster_id) return api.trove.cluster_get(self.request, cluster_id)
# TODO(michayu): assumption that cluster is homogeneous
flavor_id = self._object.instances[0]['flavor']['id']
flavors = self.get_flavors()
if flavor_id in flavors:
self._object.flavor_name = flavors[flavor_id].name
else:
flavor = api.trove.flavor_get(self.request, flavor_id)
self._object.flavor_name = flavor.name
except Exception: except Exception:
redirect = reverse("horizon:project:database_clusters:index") redirect = reverse("horizon:project:database_clusters:index")
msg = _('Unable to retrieve cluster details.') msg = _('Unable to retrieve cluster details.')
exceptions.handle(self.request, msg, redirect=redirect) exceptions.handle(self.request, msg, redirect=redirect)
return self._object
def get_flavors(self, *args, **kwargs):
if not hasattr(self, "_flavors"): class ClusterAddInstancesView(horizon_forms.ModalFormView):
form_class = forms.ClusterAddInstanceForm
form_id = "cluster_add_instances_form"
modal_header = _("Add Instance")
modal_id = "cluster_add_instances_modal"
template_name = "project/database_clusters/add_instance.html"
submit_label = _("Add")
submit_url = "horizon:project:database_clusters:add_instance"
success_url = "horizon:project:database_clusters:cluster_grow_details"
cancel_url = "horizon:project:database_clusters:cluster_grow_details"
page_title = _("Add Instance")
def get_context_data(self, **kwargs):
context = (super(ClusterAddInstancesView, self)
.get_context_data(**kwargs))
context['cluster_id'] = self.kwargs['cluster_id']
args = (self.kwargs['cluster_id'],)
context['submit_url'] = reverse(self.submit_url, args=args)
return context
def get_success_url(self):
return reverse(self.success_url, args=[self.kwargs['cluster_id']])
def get_cancel_url(self):
return reverse(self.cancel_url, args=[self.kwargs['cluster_id']])
class ClusterInstance(object):
def __init__(self, id, name, status):
self.id = id
self.name = name
self.status = status
class ClusterShrinkView(horizon_tables.DataTableView):
table_class = tables.ClusterShrinkInstancesTable
template_name = "project/database_clusters/cluster_shrink_details.html"
page_title = _("Shrink Cluster: {{cluster_name}}")
@memoized.memoized_method
def get_cluster(self, cluster_id):
try: try:
flavors = api.trove.flavor_list(self.request) return api.trove.cluster_get(self.request, cluster_id)
self._flavors = OrderedDict([(str(flavor.id), flavor)
for flavor in flavors])
except Exception: except Exception:
redirect = reverse("horizon:project:database_clusters:index") redirect = reverse("horizon:project:database_clusters:index")
exceptions.handle( msg = _('Unable to retrieve cluster details.')
self.request, exceptions.handle(self.request, msg, redirect=redirect)
_('Unable to retrieve flavors.'), redirect=redirect)
return self._flavors
def get_initial(self): def get_data(self):
initial = super(AddShardView, self).get_initial() cluster = self.get_cluster(self.kwargs['cluster_id'])
_object = self.get_object() instances = [ClusterInstance(i['id'], i['name'], i['status'])
if _object: for i in cluster.instances]
initial.update( return instances
{'cluster_id': self.kwargs['cluster_id'],
'name': getattr(_object, 'name', None)}) def get_context_data(self, **kwargs):
return initial context = super(ClusterShrinkView, self).get_context_data(**kwargs)
context['cluster_id'] = self.kwargs['cluster_id']
cluster = self.get_cluster(self.kwargs['cluster_id'])
context['cluster_name'] = cluster.name
return context
class ResetPasswordView(horizon_forms.ModalFormView): class ResetPasswordView(horizon_forms.ModalFormView):

View File

@ -21,6 +21,10 @@ VERTICA = "vertica"
_cluster_capable_datastores = (MONGODB, PERCONA_CLUSTER, REDIS, VERTICA) _cluster_capable_datastores = (MONGODB, PERCONA_CLUSTER, REDIS, VERTICA)
def can_modify_cluster(datastore):
return (is_mongodb_datastore(datastore) or is_redis_datastore(datastore))
def is_mongodb_datastore(datastore): def is_mongodb_datastore(datastore):
return (datastore is not None) and (MONGODB in datastore.lower()) return (datastore is not None) and (MONGODB in datastore.lower())

View File

@ -40,6 +40,8 @@ CLUSTER_DATA_ONE = {
{ {
"id": "416b0b16-ba55-4302-bbd3-ff566032e1c1", "id": "416b0b16-ba55-4302-bbd3-ff566032e1c1",
"shard_id": "5415b62f-f301-4e84-ba90-8ab0734d15a7", "shard_id": "5415b62f-f301-4e84-ba90-8ab0734d15a7",
"name": "inst1",
"status": "ACTIVE",
"flavor": { "flavor": {
"id": "7", "id": "7",
"links": [] "links": []
@ -51,6 +53,8 @@ CLUSTER_DATA_ONE = {
{ {
"id": "965ef811-7c1d-47fc-89f2-a89dfdd23ef2", "id": "965ef811-7c1d-47fc-89f2-a89dfdd23ef2",
"shard_id": "5415b62f-f301-4e84-ba90-8ab0734d15a7", "shard_id": "5415b62f-f301-4e84-ba90-8ab0734d15a7",
"name": "inst2",
"status": "ACTIVE",
"flavor": { "flavor": {
"id": "7", "id": "7",
"links": [] "links": []
@ -62,6 +66,8 @@ CLUSTER_DATA_ONE = {
{ {
"id": "3642f41c-e8ad-4164-a089-3891bf7f2d2b", "id": "3642f41c-e8ad-4164-a089-3891bf7f2d2b",
"shard_id": "5415b62f-f301-4e84-ba90-8ab0734d15a7", "shard_id": "5415b62f-f301-4e84-ba90-8ab0734d15a7",
"name": "inst3",
"status": "ACTIVE",
"flavor": { "flavor": {
"id": "7", "id": "7",
"links": [] "links": []
@ -91,6 +97,8 @@ CLUSTER_DATA_TWO = {
"instances": [ "instances": [
{ {
"id": "416b0b16-ba55-4302-bbd3-ff566032e1c1", "id": "416b0b16-ba55-4302-bbd3-ff566032e1c1",
"name": "inst1",
"status": "ACTIVE",
"flavor": { "flavor": {
"id": "7", "id": "7",
"links": [] "links": []
@ -101,6 +109,8 @@ CLUSTER_DATA_TWO = {
}, },
{ {
"id": "965ef811-7c1d-47fc-89f2-a89dfdd23ef2", "id": "965ef811-7c1d-47fc-89f2-a89dfdd23ef2",
"name": "inst2",
"status": "ACTIVE",
"flavor": { "flavor": {
"id": "7", "id": "7",
"links": [] "links": []
@ -111,6 +121,8 @@ CLUSTER_DATA_TWO = {
}, },
{ {
"id": "3642f41c-e8ad-4164-a089-3891bf7f2d2b", "id": "3642f41c-e8ad-4164-a089-3891bf7f2d2b",
"name": "inst3",
"status": "ACTIVE",
"flavor": { "flavor": {
"id": "7", "id": "7",
"links": [] "links": []