diff --git a/trove_dashboard/api/trove.py b/trove_dashboard/api/trove.py index 9490f166..44ba17f8 100644 --- a/trove_dashboard/api/trove.py +++ b/trove_dashboard/api/trove.py @@ -76,8 +76,25 @@ def cluster_create(request, name, volume, flavor, num_instances, instances=instances) -def cluster_add_shard(request, cluster_id): - return troveclient(request).clusters.add_shard(cluster_id) +def cluster_grow(request, cluster_id, new_instances): + 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): diff --git a/trove_dashboard/content/database_clusters/cluster_manager.py b/trove_dashboard/content/database_clusters/cluster_manager.py new file mode 100644 index 00000000..e4f039e0 --- /dev/null +++ b/trove_dashboard/content/database_clusters/cluster_manager.py @@ -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 diff --git a/trove_dashboard/content/database_clusters/forms.py b/trove_dashboard/content/database_clusters/forms.py index 5a3142ab..ba75f8db 100644 --- a/trove_dashboard/content/database_clusters/forms.py +++ b/trove_dashboard/content/database_clusters/forms.py @@ -14,6 +14,7 @@ # under the License. import logging +import uuid from django.core.urlresolvers import reverse from django.utils.translation import ugettext_lazy as _ @@ -26,6 +27,8 @@ from horizon.utils import memoized from openstack_dashboard import 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 LOG = logging.getLogger(__name__) @@ -314,39 +317,78 @@ class LaunchForm(forms.SelfHandlingForm): redirect=redirect) -class AddShardForm(forms.SelfHandlingForm): - name = forms.CharField( - label=_("Cluster Name"), - max_length=80, - widget=forms.TextInput(attrs={'readonly': 'readonly'})) - num_shards = forms.IntegerField( - label=_("Number of Shards"), +class ClusterAddInstanceForm(forms.SelfHandlingForm): + cluster_id = forms.CharField( + required=False, + 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, - 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()) + 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): try: - LOG.info("Adding shard with parameters " - "{name=%s, num_shards=%s, num_instances=%s, " - "cluster_id=%s}", - data['name'], - data['num_shards'], - data['num_instances'], - data['cluster_id']) - trove_api.trove.cluster_add_shard(request, data['cluster_id']) - - messages.success(request, - _('Added shard to "%s"') % data['name']) + flavor = trove_api.trove.flavor_get(request, data['flavor']) + manager = cluster_manager.get(data['cluster_id']) + manager.add_instance(str(uuid.uuid4()), + data.get('name', None), + data['flavor'], + flavor.name, + data['volume'], + data.get('type', None), + data.get('related_to', None)) except Exception as e: redirect = reverse("horizon:project:database_clusters:index") exceptions.handle(request, - _('Unable to add shard. %s') % e.message, + _('Unable to grow cluster. %s') % e.message, redirect=redirect) return True diff --git a/trove_dashboard/content/database_clusters/tables.py b/trove_dashboard/content/database_clusters/tables.py index a08ce9a3..9cccc4b0 100644 --- a/trove_dashboard/content/database_clusters/tables.py +++ b/trove_dashboard/content/database_clusters/tables.py @@ -14,19 +14,27 @@ # License for the specific language governing permissions and limitations # under the License. +import logging + from django.core import urlresolvers +from django import shortcuts from django.template.defaultfilters import title # noqa from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ungettext_lazy +from horizon import messages from horizon import tables from horizon.templatetags import sizeformat from horizon.utils import filters +from horizon.utils import functions from horizon.utils import memoized from trove_dashboard import api +from trove_dashboard.content.database_clusters import cluster_manager from trove_dashboard.content.databases import db_capability +LOG = logging.getLogger(__name__) + ACTIVE_STATES = ("ACTIVE",) @@ -64,16 +72,29 @@ class LaunchLink(tables.LinkAction): icon = "cloud-upload" -class AddShard(tables.LinkAction): - name = "add_shard" - verbose_name = _("Add Shard") - url = "horizon:project:database_clusters:add_shard" - classes = ("ajax-modal",) +class ClusterGrow(tables.LinkAction): + name = "cluster_grow" + verbose_name = _("Grow Cluster") + url = "horizon:project:database_clusters:cluster_grow_details" icon = "plus" def allowed(self, request, cluster=None): 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 False @@ -163,7 +184,8 @@ class ClustersTable(tables.DataTable): status_columns = ["task"] row_class = UpdateRow table_actions = (LaunchLink, DeleteCluster) - row_actions = (AddShard, ResetPassword, DeleteCluster) + row_actions = (ClusterGrow, ClusterShrink, ResetPassword, + DeleteCluster) def get_instance_size(instance): @@ -206,3 +228,208 @@ class InstancesTable(tables.DataTable): class Meta(object): 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,) diff --git a/trove_dashboard/content/database_clusters/templates/database_clusters/_add_instance.html b/trove_dashboard/content/database_clusters/templates/database_clusters/_add_instance.html new file mode 100644 index 00000000..2961af7e --- /dev/null +++ b/trove_dashboard/content/database_clusters/templates/database_clusters/_add_instance.html @@ -0,0 +1,8 @@ +{% extends "horizon/common/_modal_form.html" %} +{% load i18n %} + +{% block modal-body-right %} +

{% trans "Specify the details of the instance to be added to the cluster." %}

+

{% trans "The name field is optional. If the field is left blank a name will be generated when the cluster is grown." %}

+

{% trans "The 'Instance Type' and 'Related To' fields are datastore specific and optional. See the Trove documentation for more information on using these fields." %}

+{% endblock %} diff --git a/trove_dashboard/content/database_clusters/templates/database_clusters/_add_shard.html b/trove_dashboard/content/database_clusters/templates/database_clusters/_add_shard.html deleted file mode 100644 index e26a7822..00000000 --- a/trove_dashboard/content/database_clusters/templates/database_clusters/_add_shard.html +++ /dev/null @@ -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 %} -
-
- {% include "horizon/common/_form_fields.html" %} -
-
-
-

{% blocktrans %}Specify the details for adding additional shards.{% endblocktrans %}

-
-{% endblock %} - -{% block modal-footer %} - - {% trans "Cancel" %} -{% endblock %} \ No newline at end of file diff --git a/trove_dashboard/content/database_clusters/templates/database_clusters/add_instance.html b/trove_dashboard/content/database_clusters/templates/database_clusters/add_instance.html new file mode 100644 index 00000000..eada158e --- /dev/null +++ b/trove_dashboard/content/database_clusters/templates/database_clusters/add_instance.html @@ -0,0 +1,5 @@ +{% extends "base.html" %} + +{% block main %} + {% include "project/database_clusters/_add_instance.html" %} +{% endblock %} \ No newline at end of file diff --git a/trove_dashboard/content/database_clusters/templates/database_clusters/add_shard.html b/trove_dashboard/content/database_clusters/templates/database_clusters/add_shard.html deleted file mode 100644 index ef9d23d3..00000000 --- a/trove_dashboard/content/database_clusters/templates/database_clusters/add_shard.html +++ /dev/null @@ -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 %} \ No newline at end of file diff --git a/trove_dashboard/content/database_clusters/templates/database_clusters/cluster_grow_details.html b/trove_dashboard/content/database_clusters/templates/database_clusters/cluster_grow_details.html new file mode 100644 index 00000000..fa64abb0 --- /dev/null +++ b/trove_dashboard/content/database_clusters/templates/database_clusters/cluster_grow_details.html @@ -0,0 +1,14 @@ +{% extends 'base.html' %} +{% load i18n %} + +{% block main %} +
+
+ {% trans "Specify the instances to be added to the cluster. When all the instances are specified click 'Grow Cluster' to perform the grow operation." %} +
+
+
+ {{ table.render }} +
+
+{% endblock %} diff --git a/trove_dashboard/content/database_clusters/templates/database_clusters/cluster_shrink_details.html b/trove_dashboard/content/database_clusters/templates/database_clusters/cluster_shrink_details.html new file mode 100644 index 00000000..941afa51 --- /dev/null +++ b/trove_dashboard/content/database_clusters/templates/database_clusters/cluster_shrink_details.html @@ -0,0 +1,14 @@ +{% extends 'base.html' %} +{% load i18n %} + +{% block main %} +
+
+ {% trans "Select the instance(s) that will be removed from the cluster." %} +
+
+
+ {{ table.render }} +
+
+{% endblock %} diff --git a/trove_dashboard/content/database_clusters/tests.py b/trove_dashboard/content/database_clusters/tests.py index a34bd6c9..bbb91d58 100644 --- a/trove_dashboard/content/database_clusters/tests.py +++ b/trove_dashboard/content/database_clusters/tests.py @@ -23,12 +23,14 @@ from openstack_dashboard import api from troveclient import common 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 INDEX_URL = reverse('horizon:project:database_clusters:index') LAUNCH_URL = reverse('horizon:project:database_clusters:launch') 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' @@ -376,6 +378,179 @@ class ClustersTests(test.TestCase): self.assertTemplateUsed(res, 'horizon/common/_detail.html') 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): filtered_datastore = [] for ds in self.datastores.list(): diff --git a/trove_dashboard/content/database_clusters/urls.py b/trove_dashboard/content/database_clusters/urls.py index f2c5c622..076ac513 100644 --- a/trove_dashboard/content/database_clusters/urls.py +++ b/trove_dashboard/content/database_clusters/urls.py @@ -27,8 +27,16 @@ urlpatterns = patterns( url(r'^launch$', views.LaunchClusterView.as_view(), name='launch'), url(r'^(?P[^/]+)/$', views.DetailView.as_view(), name='detail'), - url(CLUSTERS % 'add_shard', views.AddShardView.as_view(), - name='add_shard'), - url(CLUSTERS % 'reset_password', views.ResetPasswordView.as_view(), + url(CLUSTERS % 'cluster_grow_details', + views.ClusterGrowView.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'), ) diff --git a/trove_dashboard/content/database_clusters/views.py b/trove_dashboard/content/database_clusters/views.py index d335e3cf..40115ef6 100644 --- a/trove_dashboard/content/database_clusters/views.py +++ b/trove_dashboard/content/database_clusters/views.py @@ -33,6 +33,8 @@ from horizon import tabs as horizon_tabs from horizon.utils import memoized 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 tables 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) -class AddShardView(horizon_forms.ModalFormView): - form_class = forms.AddShardForm - template_name = 'project/database_clusters/add_shard.html' - success_url = reverse_lazy('horizon:project:database_clusters:index') - page_title = _("Add Shard") +class ClusterGrowView(horizon_tables.DataTableView): + table_class = tables.ClusterGrowInstancesTable + template_name = 'project/database_clusters/cluster_grow_details.html' + page_title = _("Grow Cluster: {{cluster_name}}") + + def get_data(self): + manager = cluster_manager.get(self.kwargs['cluster_id']) + return manager.get_instances() def get_context_data(self, **kwargs): - context = super(AddShardView, self).get_context_data(**kwargs) - context["cluster_id"] = self.kwargs['cluster_id'] + context = super(ClusterGrowView, 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 - def get_object(self, *args, **kwargs): - if not hasattr(self, "_object"): - cluster_id = self.kwargs['cluster_id'] - try: - self._object = 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: - redirect = reverse("horizon:project:database_clusters:index") - msg = _('Unable to retrieve cluster details.') - exceptions.handle(self.request, msg, redirect=redirect) - return self._object + @memoized.memoized_method + def get_cluster(self, cluster_id): + try: + return api.trove.cluster_get(self.request, cluster_id) + except Exception: + redirect = reverse("horizon:project:database_clusters:index") + msg = _('Unable to retrieve cluster details.') + exceptions.handle(self.request, msg, redirect=redirect) - def get_flavors(self, *args, **kwargs): - if not hasattr(self, "_flavors"): - try: - flavors = api.trove.flavor_list(self.request) - self._flavors = OrderedDict([(str(flavor.id), flavor) - for flavor in flavors]) - except Exception: - redirect = reverse("horizon:project:database_clusters:index") - exceptions.handle( - self.request, - _('Unable to retrieve flavors.'), redirect=redirect) - return self._flavors - def get_initial(self): - initial = super(AddShardView, self).get_initial() - _object = self.get_object() - if _object: - initial.update( - {'cluster_id': self.kwargs['cluster_id'], - 'name': getattr(_object, 'name', None)}) - return initial +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: + return api.trove.cluster_get(self.request, cluster_id) + except Exception: + redirect = reverse("horizon:project:database_clusters:index") + msg = _('Unable to retrieve cluster details.') + exceptions.handle(self.request, msg, redirect=redirect) + + def get_data(self): + cluster = self.get_cluster(self.kwargs['cluster_id']) + instances = [ClusterInstance(i['id'], i['name'], i['status']) + for i in cluster.instances] + return instances + + def get_context_data(self, **kwargs): + 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): diff --git a/trove_dashboard/content/databases/db_capability.py b/trove_dashboard/content/databases/db_capability.py index 25dfc55f..ef9d2bfb 100644 --- a/trove_dashboard/content/databases/db_capability.py +++ b/trove_dashboard/content/databases/db_capability.py @@ -21,6 +21,10 @@ VERTICA = "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): return (datastore is not None) and (MONGODB in datastore.lower()) diff --git a/trove_dashboard/test/test_data/trove_data.py b/trove_dashboard/test/test_data/trove_data.py index c131ae2d..bb89a3de 100644 --- a/trove_dashboard/test/test_data/trove_data.py +++ b/trove_dashboard/test/test_data/trove_data.py @@ -40,6 +40,8 @@ CLUSTER_DATA_ONE = { { "id": "416b0b16-ba55-4302-bbd3-ff566032e1c1", "shard_id": "5415b62f-f301-4e84-ba90-8ab0734d15a7", + "name": "inst1", + "status": "ACTIVE", "flavor": { "id": "7", "links": [] @@ -51,6 +53,8 @@ CLUSTER_DATA_ONE = { { "id": "965ef811-7c1d-47fc-89f2-a89dfdd23ef2", "shard_id": "5415b62f-f301-4e84-ba90-8ab0734d15a7", + "name": "inst2", + "status": "ACTIVE", "flavor": { "id": "7", "links": [] @@ -62,6 +66,8 @@ CLUSTER_DATA_ONE = { { "id": "3642f41c-e8ad-4164-a089-3891bf7f2d2b", "shard_id": "5415b62f-f301-4e84-ba90-8ab0734d15a7", + "name": "inst3", + "status": "ACTIVE", "flavor": { "id": "7", "links": [] @@ -91,6 +97,8 @@ CLUSTER_DATA_TWO = { "instances": [ { "id": "416b0b16-ba55-4302-bbd3-ff566032e1c1", + "name": "inst1", + "status": "ACTIVE", "flavor": { "id": "7", "links": [] @@ -101,6 +109,8 @@ CLUSTER_DATA_TWO = { }, { "id": "965ef811-7c1d-47fc-89f2-a89dfdd23ef2", + "name": "inst2", + "status": "ACTIVE", "flavor": { "id": "7", "links": [] @@ -111,6 +121,8 @@ CLUSTER_DATA_TWO = { }, { "id": "3642f41c-e8ad-4164-a089-3891bf7f2d2b", + "name": "inst3", + "status": "ACTIVE", "flavor": { "id": "7", "links": []