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." %}
{% 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." %}
+