From 57295f42ba30f37357f2dc5713a87f400bfa2134 Mon Sep 17 00:00:00 2001 From: Sushil Kumar Date: Wed, 26 Aug 2015 09:51:04 +0000 Subject: [PATCH] Initial support for database clustering in Horizon Added a separate panel for clusters. This panel contains the Clusters Table with table actions to Launch and Terminate clusters. There are row actions Add Shard and Reset Password (and their associated dialogs) that are specific to MongoDB and Vertica respectively. The Clusters Details will include the following tabs: - Overview - Instances (table of instances belonging to this cluster) The launch panel has custom fields for MongoDB and Vertica. The custom fields will be dynamically shown based on the datastore selected. Added a db_capability utility to aid in identifying the specific datastores. Added network selection dropdown if neutron is enabled. Co-Authored-By: Sushil Kumar Co-Authored-By: Saurabh Surana Co-Authored-By: Duk Loi Co-Authored-By: Anna Shen Change-Id: I047f4d37449070adfd0ea66ad010982f35c049aa Implements: blueprint database-clustering-support --- .../contrib/trove/api/trove.py | 64 +++ .../content/database_clusters/__init__.py | 0 .../trove/content/database_clusters/forms.py | 375 ++++++++++++++++++ .../trove/content/database_clusters/panel.py | 26 ++ .../trove/content/database_clusters/tables.py | 205 ++++++++++ .../trove/content/database_clusters/tabs.py | 84 ++++ .../database_clusters/_add_shard.html | 25 ++ .../database_clusters/_detail_overview.html | 27 ++ .../_detail_overview_mongodb.html | 27 ++ .../_detail_overview_vertica.html | 29 ++ .../templates/database_clusters/_launch.html | 22 + .../database_clusters/_reset_password.html | 25 ++ .../database_clusters/add_shard.html | 7 + .../templates/database_clusters/detail.html | 12 + .../templates/database_clusters/index.html | 11 + .../templates/database_clusters/launch.html | 11 + .../database_clusters/reset_password.html | 7 + .../trove/content/database_clusters/tests.py | 295 ++++++++++++++ .../trove/content/database_clusters/urls.py | 34 ++ .../trove/content/database_clusters/views.py | 212 ++++++++++ .../trove/content/databases/db_capability.py | 25 ++ .../databases/_detail_overview_cassandra.html | 4 +- .../databases/_detail_overview_mongodb.html | 42 +- .../contrib/trove/content/databases/tests.py | 8 +- .../databases/workflows/create_instance.py | 2 +- .../_1740_project_database_clusters_panel.py | 25 ++ .../test/test_data/trove_data.py | 139 ++++++- 27 files changed, 1712 insertions(+), 31 deletions(-) create mode 100644 openstack_dashboard/contrib/trove/content/database_clusters/__init__.py create mode 100644 openstack_dashboard/contrib/trove/content/database_clusters/forms.py create mode 100644 openstack_dashboard/contrib/trove/content/database_clusters/panel.py create mode 100644 openstack_dashboard/contrib/trove/content/database_clusters/tables.py create mode 100644 openstack_dashboard/contrib/trove/content/database_clusters/tabs.py create mode 100644 openstack_dashboard/contrib/trove/content/database_clusters/templates/database_clusters/_add_shard.html create mode 100644 openstack_dashboard/contrib/trove/content/database_clusters/templates/database_clusters/_detail_overview.html create mode 100644 openstack_dashboard/contrib/trove/content/database_clusters/templates/database_clusters/_detail_overview_mongodb.html create mode 100644 openstack_dashboard/contrib/trove/content/database_clusters/templates/database_clusters/_detail_overview_vertica.html create mode 100644 openstack_dashboard/contrib/trove/content/database_clusters/templates/database_clusters/_launch.html create mode 100644 openstack_dashboard/contrib/trove/content/database_clusters/templates/database_clusters/_reset_password.html create mode 100644 openstack_dashboard/contrib/trove/content/database_clusters/templates/database_clusters/add_shard.html create mode 100644 openstack_dashboard/contrib/trove/content/database_clusters/templates/database_clusters/detail.html create mode 100644 openstack_dashboard/contrib/trove/content/database_clusters/templates/database_clusters/index.html create mode 100644 openstack_dashboard/contrib/trove/content/database_clusters/templates/database_clusters/launch.html create mode 100644 openstack_dashboard/contrib/trove/content/database_clusters/templates/database_clusters/reset_password.html create mode 100644 openstack_dashboard/contrib/trove/content/database_clusters/tests.py create mode 100644 openstack_dashboard/contrib/trove/content/database_clusters/urls.py create mode 100644 openstack_dashboard/contrib/trove/content/database_clusters/views.py create mode 100644 openstack_dashboard/contrib/trove/content/databases/db_capability.py create mode 100644 openstack_dashboard/enabled/_1740_project_database_clusters_panel.py diff --git a/openstack_dashboard/contrib/trove/api/trove.py b/openstack_dashboard/contrib/trove/api/trove.py index e017081918..e55d9b1c1a 100644 --- a/openstack_dashboard/contrib/trove/api/trove.py +++ b/openstack_dashboard/contrib/trove/api/trove.py @@ -42,6 +42,57 @@ def troveclient(request): return c +def cluster_list(request, marker=None): + page_size = utils.get_page_size(request) + return troveclient(request).clusters.list(limit=page_size, marker=marker) + + +def cluster_get(request, cluster_id): + return troveclient(request).clusters.get(cluster_id) + + +def cluster_delete(request, cluster_id): + return troveclient(request).clusters.delete(cluster_id) + + +def cluster_create(request, name, volume, flavor, num_instances, + datastore, datastore_version, + nics=None, root_password=None): + # TODO(dklyle): adding to support trove without volume + # support for now until API supports checking for volume support + if volume > 0: + volume_params = {'size': volume} + else: + volume_params = None + instances = [] + for i in range(num_instances): + instance = {} + instance["flavorRef"] = flavor + instance["volume"] = volume_params + if nics: + instance["nics"] = [{"net-id": nics}] + instances.append(instance) + + # TODO(saurabhs): vertica needs root password on cluster create + return troveclient(request).clusters.create( + name, + datastore, + datastore_version, + instances=instances) + + +def cluster_add_shard(request, cluster_id): + return troveclient(request).clusters.add_shard(cluster_id) + + +def create_cluster_root(request, cluster_id, password): + # It appears the code below depends on this trove change + # https://review.openstack.org/#/c/166954/. Comment out when that + # change merges. + # return troveclient(request).cluster.reset_root_password(cluster_id) + troveclient(request).root.create_cluster_root(cluster_id, password) + + def instance_list(request, marker=None): page_size = utils.get_page_size(request) return troveclient(request).instances.list(limit=page_size, marker=marker) @@ -130,6 +181,19 @@ def flavor_list(request): return troveclient(request).flavors.list() +def datastore_flavors(request, datastore_name=None, + datastore_version=None): + # if datastore info is available then get datastore specific flavors + if datastore_name and datastore_version: + try: + return troveclient(request).flavors.\ + list_datastore_version_associated_flavors(datastore_name, + datastore_version) + except Exception: + LOG.warn("Failed to retrieve datastore specific flavors") + return flavor_list(request) + + def flavor_get(request, flavor_id): return troveclient(request).flavors.get(flavor_id) diff --git a/openstack_dashboard/contrib/trove/content/database_clusters/__init__.py b/openstack_dashboard/contrib/trove/content/database_clusters/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/openstack_dashboard/contrib/trove/content/database_clusters/forms.py b/openstack_dashboard/contrib/trove/content/database_clusters/forms.py new file mode 100644 index 0000000000..a08a84602a --- /dev/null +++ b/openstack_dashboard/contrib/trove/content/database_clusters/forms.py @@ -0,0 +1,375 @@ +# Copyright 2015 HP Software, LLC +# All Rights Reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import logging + +from django.core.urlresolvers import reverse +from django.utils.translation import ugettext_lazy as _ +from django.views.decorators.debug import sensitive_variables # noqa + +from horizon import exceptions +from horizon import forms +from horizon import messages +from horizon.utils import memoized + +from openstack_dashboard import api +from openstack_dashboard.contrib.trove import api as trove_api +from openstack_dashboard.contrib.trove.content.databases import db_capability + +LOG = logging.getLogger(__name__) + + +class LaunchForm(forms.SelfHandlingForm): + name = forms.CharField(label=_("Cluster Name"), + max_length=80) + datastore = forms.ChoiceField( + label=_("Datastore"), + help_text=_("Type and version of datastore."), + widget=forms.Select(attrs={ + 'class': 'switchable', + 'data-slug': 'datastore' + })) + mongodb_flavor = forms.ChoiceField( + label=_("Flavor"), + help_text=_("Size of instance to launch."), + required=False, + widget=forms.Select(attrs={ + 'class': 'switched', + 'data-switch-on': 'datastore', + })) + vertica_flavor = forms.ChoiceField( + label=_("Flavor"), + help_text=_("Size of instance to launch."), + required=False, + widget=forms.Select(attrs={ + 'class': 'switched', + 'data-switch-on': 'datastore', + })) + network = forms.ChoiceField( + label=_("Network"), + help_text=_("Network attached to instance."), + required=False) + volume = forms.IntegerField( + label=_("Volume Size"), + min_value=0, + initial=1, + help_text=_("Size of the volume in GB.")) + root_password = forms.CharField( + label=_("Root Password"), + required=False, + help_text=_("Password for root user."), + widget=forms.PasswordInput(attrs={ + 'class': 'switched', + 'data-switch-on': 'datastore', + })) + num_instances_vertica = forms.IntegerField( + label=_("Number of Instances"), + min_value=3, + initial=3, + required=False, + help_text=_("Number of instances in the cluster. (Read only)"), + widget=forms.TextInput(attrs={ + 'readonly': 'readonly', + 'class': 'switched', + 'data-switch-on': 'datastore', + })) + num_shards = forms.IntegerField( + label=_("Number of Shards"), + min_value=1, + initial=1, + required=False, + help_text=_("Number of shards. (Read only)"), + widget=forms.TextInput(attrs={ + 'readonly': 'readonly', + 'class': 'switched', + 'data-switch-on': 'datastore', + })) + num_instances_per_shards = forms.IntegerField( + label=_("Instances Per Shard"), + initial=3, + required=False, + help_text=_("Number of instances per shard. (Read only)"), + widget=forms.TextInput(attrs={ + 'readonly': 'readonly', + 'class': 'switched', + 'data-switch-on': 'datastore', + })) + + # (name of field variable, label) + mongodb_fields = [ + ('mongodb_flavor', _('Flavor')), + ('num_shards', _('Number of Shards')), + ('num_instances_per_shards', _('Instances Per Shard')) + ] + vertica_fields = [ + ('num_instances_vertica', ('Number of Instances')), + ('vertica_flavor', _('Flavor')), + ('root_password', _('Root Password')), + ] + + def __init__(self, request, *args, **kwargs): + super(LaunchForm, self).__init__(request, *args, **kwargs) + + self.fields['datastore'].choices = self.populate_datastore_choices( + request) + self.populate_flavor_choices(request) + + self.fields['network'].choices = self.populate_network_choices( + request) + + def clean(self): + datastore_field_value = self.data.get("datastore", None) + if datastore_field_value: + datastore = datastore_field_value.split(',')[0] + + if db_capability.is_mongodb_datastore(datastore): + if self.data.get("num_shards", None) < 1: + msg = _("The number of shards must be greater than 1.") + self._errors["num_shards"] = self.error_class([msg]) + + elif db_capability.is_vertica_datastore(datastore): + if not self.data.get("vertica_flavor", None): + msg = _("The flavor must be specified.") + self._errors["vertica_flavor"] = self.error_class([msg]) + if not self.data.get("root_password", None): + msg = _("Password for root user must be specified.") + self._errors["root_password"] = self.error_class([msg]) + + return self.cleaned_data + + @memoized.memoized_method + def datastore_flavors(self, request, datastore_name, datastore_version): + try: + return trove_api.trove.datastore_flavors( + request, datastore_name, 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): + valid_flavor = [] + for ds in self.datastores(request): + # TODO(michayu): until capabilities lands + if db_capability.is_mongodb_datastore(ds.name): + versions = self.datastore_versions(request, ds.name) + for version in versions: + if version.name == "inactive": + continue + valid_flavor = self.datastore_flavors(request, ds.name, + versions[0].name) + if valid_flavor: + self.fields['mongodb_flavor'].choices = sorted( + [(f.id, "%s" % f.name) for f in valid_flavor]) + + if db_capability.is_vertica_datastore(ds.name): + versions = self.datastore_versions(request, ds.name) + for version in versions: + if version.name == "inactive": + continue + valid_flavor = self.datastore_flavors(request, ds.name, + versions[0].name) + if valid_flavor: + self.fields['vertica_flavor'].choices = sorted( + [(f.id, "%s" % f.name) for f in valid_flavor]) + + @memoized.memoized_method + def populate_network_choices(self, request): + network_list = [] + try: + if api.base.is_service_enabled(request, 'network'): + tenant_id = self.request.user.tenant_id + networks = api.neutron.network_list_for_tenant(request, + tenant_id) + network_list = [(network.id, network.name_or_id) + for network in networks] + else: + self.fields['network'].widget = forms.HiddenInput() + except exceptions.ServiceCatalogException: + network_list = [] + redirect = reverse('horizon:project:database_clusters:index') + exceptions.handle(request, + _('Unable to retrieve networks.'), + redirect=redirect) + return network_list + + @memoized.memoized_method + def datastores(self, request): + try: + return trove_api.trove.datastore_list(request) + except Exception: + LOG.exception("Exception while obtaining datastores list") + self._datastores = [] + redirect = reverse('horizon:project:database_clusters:index') + exceptions.handle(request, + _('Unable to obtain datastores.'), + redirect=redirect) + + def filter_cluster_datastores(self, request): + datastores = [] + for ds in self.datastores(request): + # TODO(michayu): until capabilities lands + if (db_capability.is_vertica_datastore(ds.name) + or db_capability.is_mongodb_datastore(ds.name)): + datastores.append(ds) + return datastores + + @memoized.memoized_method + def datastore_versions(self, request, datastore): + try: + return trove_api.trove.datastore_version_list(request, datastore) + except Exception: + LOG.exception("Exception while obtaining datastore version list") + self._datastore_versions = [] + redirect = reverse('horizon:project:database_clusters:index') + exceptions.handle(request, + _('Unable to obtain datastore versions.'), + redirect=redirect) + + def populate_datastore_choices(self, request): + choices = () + datastores = self.filter_cluster_datastores(request) + if datastores is not None: + for ds in datastores: + versions = self.datastore_versions(request, ds.name) + if versions: + # only add to choices if datastore has at least one version + version_choices = () + for v in versions: + if "inactive" in v.name: + continue + selection_text = ds.name + ' - ' + v.name + widget_text = ds.name + '-' + v.name + version_choices = (version_choices + + ((widget_text, selection_text),)) + self._add_attr_to_optional_fields(ds.name, + widget_text) + + choices = choices + version_choices + return choices + + def _add_attr_to_optional_fields(self, datastore, selection_text): + fields = [] + if db_capability.is_mongodb_datastore(datastore): + fields = self.mongodb_fields + elif db_capability.is_vertica_datastore(datastore): + fields = self.vertica_fields + + for field in fields: + attr_key = 'data-datastore-' + selection_text + widget = self.fields[field[0]].widget + if attr_key not in widget.attrs: + widget.attrs[attr_key] = field[1] + + @sensitive_variables('data') + def handle(self, request, data): + try: + datastore = data['datastore'].split('-')[0] + datastore_version = data['datastore'].split('-')[1] + + final_flavor = data['mongodb_flavor'] + num_instances = data['num_instances_per_shards'] + root_password = None + if db_capability.is_vertica_datastore(datastore): + final_flavor = data['vertica_flavor'] + root_password = data['root_password'] + num_instances = data['num_instances_vertica'] + LOG.info("Launching cluster with parameters " + "{name=%s, volume=%s, flavor=%s, " + "datastore=%s, datastore_version=%s", + data['name'], data['volume'], final_flavor, + datastore, datastore_version) + + trove_api.trove.cluster_create(request, + data['name'], + data['volume'], + final_flavor, + num_instances, + datastore=datastore, + datastore_version=datastore_version, + nics=data['network'], + root_password=root_password) + messages.success(request, + _('Launched cluster "%s"') % data['name']) + return True + except Exception as e: + redirect = reverse("horizon:project:database_clusters:index") + exceptions.handle(request, + _('Unable to launch cluster. %s') % e.message, + 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"), + 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()) + + 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']) + except Exception as e: + redirect = reverse("horizon:project:database_clusters:index") + exceptions.handle(request, + _('Unable to add shard. %s') % e.message, + redirect=redirect) + return True + + +class ResetPasswordForm(forms.SelfHandlingForm): + cluster_id = forms.CharField(widget=forms.HiddenInput()) + password = forms.CharField(widget=forms.PasswordInput(), + label=_("New Password"), + required=True, + help_text=_("New password for cluster access.")) + + @sensitive_variables('data') + def handle(self, request, data): + password = data.get("password") + cluster_id = data.get("cluster_id") + try: + trove_api.trove.create_cluster_root(request, + cluster_id, + password) + messages.success(request, _('Root password updated for ' + 'cluster "%s"') % cluster_id) + except Exception as e: + redirect = reverse("horizon:project:database_clusters:index") + exceptions.handle(request, _('Unable to reset password. %s') % + e.message, redirect=redirect) + return True diff --git a/openstack_dashboard/contrib/trove/content/database_clusters/panel.py b/openstack_dashboard/contrib/trove/content/database_clusters/panel.py new file mode 100644 index 0000000000..1575ff376c --- /dev/null +++ b/openstack_dashboard/contrib/trove/content/database_clusters/panel.py @@ -0,0 +1,26 @@ +# Copyright (c) 2014 eBay Software Foundation +# Copyright 2015 HP Software, LLC +# All Rights Reserved. +# +# 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.utils.translation import ugettext_lazy as _ + +import horizon + + +class Clusters(horizon.Panel): + name = _("Clusters") + slug = 'database_clusters' + permissions = ('openstack.services.database', + 'openstack.services.object-store',) diff --git a/openstack_dashboard/contrib/trove/content/database_clusters/tables.py b/openstack_dashboard/contrib/trove/content/database_clusters/tables.py new file mode 100644 index 0000000000..4e9f162f73 --- /dev/null +++ b/openstack_dashboard/contrib/trove/content/database_clusters/tables.py @@ -0,0 +1,205 @@ +# Copyright (c) 2014 eBay Software Foundation +# Copyright 2015 HP Software, LLC +# All Rights Reserved. +# +# 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 urlresolvers +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 tables +from horizon.templatetags import sizeformat +from horizon.utils import filters +from horizon.utils import memoized +from openstack_dashboard.contrib.trove import api +from openstack_dashboard.contrib.trove.content.databases import db_capability + +ACTIVE_STATES = ("ACTIVE",) + + +class TerminateCluster(tables.BatchAction): + name = "terminate" + icon = "remove" + classes = ('btn-danger',) + help_text = _("Terminated cluster is not recoverable.") + + @staticmethod + def action_present(count): + return ungettext_lazy( + u"Terminate Cluster", + u"Terminate Clusters", + count + ) + + @staticmethod + def action_past(count): + return ungettext_lazy( + u"Scheduled termination of Cluster", + u"Scheduled termination of Clusters", + count + ) + + def action(self, request, obj_id): + api.trove.cluster_delete(request, obj_id) + + +class LaunchLink(tables.LinkAction): + name = "launch" + verbose_name = _("Launch Cluster") + url = "horizon:project:database_clusters:launch" + classes = ("btn-launch", "ajax-modal") + icon = "cloud-upload" + + +class AddShard(tables.LinkAction): + name = "add_shard" + verbose_name = _("Add Shard") + url = "horizon:project:database_clusters:add_shard" + classes = ("ajax-modal",) + icon = "plus" + + def allowed(self, request, cluster=None): + if (cluster and cluster.task["name"] == 'NONE' and + db_capability.is_mongodb_datastore(cluster.datastore['type'])): + return True + return False + + +class ResetPassword(tables.LinkAction): + name = "reset_password" + verbose_name = _("Reset Root Password") + url = "horizon:project:database_clusters:reset_password" + classes = ("ajax-modal",) + + def allowed(self, request, cluster=None): + if (cluster and cluster.task["name"] == 'NONE' and + db_capability.is_vertica_datastore(cluster.datastore['type'])): + return True + return False + + def get_link_url(self, datum): + cluster_id = self.table.get_object_id(datum) + return urlresolvers.reverse(self.url, args=[cluster_id]) + + +class UpdateRow(tables.Row): + ajax = True + + @memoized.memoized_method + def get_data(self, request, cluster_id): + cluster = api.trove.cluster_get(request, cluster_id) + try: + # TODO(michayu): assumption that cluster is homogeneous + flavor_id = cluster.instances[0]['flavor']['id'] + cluster.full_flavor = api.trove.flavor_get(request, flavor_id) + except Exception: + pass + return cluster + + +def get_datastore(cluster): + return cluster.datastore["type"] + + +def get_datastore_version(cluster): + return cluster.datastore["version"] + + +def get_size(cluster): + if db_capability.is_vertica_datastore(cluster.datastore['type']): + return "3" + + if hasattr(cluster, "full_flavor"): + size_string = _("%(name)s | %(RAM)s RAM | %(instances)s instances") + vals = {'name': cluster.full_flavor.name, + 'RAM': sizeformat.mbformat(cluster.full_flavor.ram), + 'instances': len(cluster.instances)} + return size_string % vals + return _("Not available") + + +def get_task(cluster): + return cluster.task["name"] + + +class ClustersTable(tables.DataTable): + TASK_CHOICES = ( + ("none", True), + ) + name = tables.Column("name", + link=("horizon:project:database_clusters:detail"), + verbose_name=_("Cluster Name")) + datastore = tables.Column(get_datastore, + verbose_name=_("Datastore")) + datastore_version = tables.Column(get_datastore_version, + verbose_name=_("Datastore Version")) + size = tables.Column(get_size, + verbose_name=_("Cluster Size"), + attrs={'data-type': 'size'}) + task = tables.Column(get_task, + filters=(title, filters.replace_underscores), + verbose_name=_("Current Task"), + status=True, + status_choices=TASK_CHOICES) + + class Meta(object): + name = "clusters" + verbose_name = _("Clusters") + status_columns = ["task"] + row_class = UpdateRow + table_actions = (LaunchLink, TerminateCluster) + row_actions = (AddShard, ResetPassword, TerminateCluster) + + +def get_instance_size(instance): + if hasattr(instance, "full_flavor"): + size_string = _("%(name)s | %(RAM)s RAM") + vals = {'name': instance.full_flavor.name, + 'RAM': sizeformat.mbformat(instance.full_flavor.ram)} + return size_string % vals + return _("Not available") + + +def get_instance_type(instance): + if hasattr(instance, "type"): + return instance.type + return _("Not available") + + +def get_host(instance): + if hasattr(instance, "hostname"): + return instance.hostname + elif hasattr(instance, "ip") and instance.ip: + return instance.ip[0] + return _("Not Assigned") + + +class InstancesTable(tables.DataTable): + name = tables.Column("name", + verbose_name=_("Name")) + type = tables.Column(get_instance_type, + verbose_name=_("Type")) + host = tables.Column(get_host, + verbose_name=_("Host")) + size = tables.Column(get_instance_size, + verbose_name=_("Size"), + attrs={'data-type': 'size'}) + status = tables.Column("status", + filters=(title, filters.replace_underscores), + verbose_name=_("Status")) + + class Meta(object): + name = "instances" + verbose_name = _("Instances") diff --git a/openstack_dashboard/contrib/trove/content/database_clusters/tabs.py b/openstack_dashboard/contrib/trove/content/database_clusters/tabs.py new file mode 100644 index 0000000000..e107bd5600 --- /dev/null +++ b/openstack_dashboard/contrib/trove/content/database_clusters/tabs.py @@ -0,0 +1,84 @@ +# Copyright (c) 2014 eBay Software Foundation +# Copyright 2015 HP Software, LLC +# All Rights Reserved. +# +# 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 import template +from django.utils.translation import ugettext_lazy as _ + +from horizon import exceptions +from horizon import tabs +from openstack_dashboard.contrib.trove import api +from openstack_dashboard.contrib.trove.content.database_clusters import tables + + +class OverviewTab(tabs.Tab): + name = _("Overview") + slug = "overview" + + def get_context_data(self, request): + return {"cluster": self.tab_group.kwargs['cluster']} + + def get_template_name(self, request): + cluster = self.tab_group.kwargs['cluster'] + template_file = ('project/database_clusters/_detail_overview_%s.html' + % cluster.datastore['type']) + try: + template.loader.get_template(template_file) + return template_file + except template.TemplateDoesNotExist: + # This datastore type does not have a template file + # Just use the base template file + return ('project/database_clusters/_detail_overview.html') + + +class InstancesTab(tabs.TableTab): + table_classes = (tables.InstancesTable,) + name = _("Instances") + slug = "instances_tab" + cluster = None + template_name = "horizon/common/_detail_table.html" + preload = True + + def get_instances_data(self): + cluster = self.tab_group.kwargs['cluster'] + data = [] + try: + instances = api.trove.cluster_get(self.request, + cluster.id).instances + for instance in instances: + instance_info = api.trove.instance_get(self.request, + instance['id']) + flavor_id = instance_info.flavor['id'] + instance_info.full_flavor = api.trove.flavor_get(self.request, + flavor_id) + if "type" in instance: + instance_info.type = instance["type"] + if "ip" in instance: + instance_info.ip = instance["ip"] + if "hostname" in instance: + instance_info.hostname = instance["hostname"] + + data.append(instance_info) + except Exception: + msg = _('Unable to get instances data.') + exceptions.handle(self.request, msg) + data = [] + return data + + +class ClusterDetailTabs(tabs.TabGroup): + slug = "cluster_details" + tabs = (OverviewTab, InstancesTab) + sticky = True diff --git a/openstack_dashboard/contrib/trove/content/database_clusters/templates/database_clusters/_add_shard.html b/openstack_dashboard/contrib/trove/content/database_clusters/templates/database_clusters/_add_shard.html new file mode 100644 index 0000000000..e26a782210 --- /dev/null +++ b/openstack_dashboard/contrib/trove/content/database_clusters/templates/database_clusters/_add_shard.html @@ -0,0 +1,25 @@ +{% 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/openstack_dashboard/contrib/trove/content/database_clusters/templates/database_clusters/_detail_overview.html b/openstack_dashboard/contrib/trove/content/database_clusters/templates/database_clusters/_detail_overview.html new file mode 100644 index 0000000000..451b1dde79 --- /dev/null +++ b/openstack_dashboard/contrib/trove/content/database_clusters/templates/database_clusters/_detail_overview.html @@ -0,0 +1,27 @@ +{% load i18n sizeformat %} + +

{% trans "Cluster Overview" %}

+ +
+

{% trans "Information" %}

+
+
+
{% trans "Name" %}
+
{{ cluster.name }}
+
{% trans "ID" %}
+
{{ cluster.id }}
+
{% trans "Datastore" %}
+
{{ cluster.datastore.type }}
+
{% trans "Datastore Version" %}
+
{{ cluster.datastore.version }}
+
{% trans "Current Task" %}
+
{{ cluster.task.name|title }}
+
{% trans "RAM" %}
+
{{ cluster.full_flavor.ram|mbformat }}
+
{% trans "Number of Instances" %}
+
{{ cluster.num_instances }}
+
+
+ +{% block connection_info %} +{% endblock %} diff --git a/openstack_dashboard/contrib/trove/content/database_clusters/templates/database_clusters/_detail_overview_mongodb.html b/openstack_dashboard/contrib/trove/content/database_clusters/templates/database_clusters/_detail_overview_mongodb.html new file mode 100644 index 0000000000..d215a36e6c --- /dev/null +++ b/openstack_dashboard/contrib/trove/content/database_clusters/templates/database_clusters/_detail_overview_mongodb.html @@ -0,0 +1,27 @@ +{% extends "project/database_clusters/_detail_overview.html" %} +{% load i18n sizeformat %} + +{% block connection_info %} +
+

{% trans "Connection Information" %}

+
+
+ {% with cluster.ip.0 as ip %} +
{% trans "Host" %}
+
+ {% if not ip %} + {% trans "Not Assigned" %} +
+ {% else %} + {{ ip }} + +
{% trans "Database Port" %}
+
27017
+
{% trans "Connection Examples" %}
+
mongo --host {{ ip }}
+
mongodb://[{% trans "USERNAME" %}:{% trans "PASSWORD" %}@]{{ ip }}:27017/{% trans "DATABASE" %}
+ {% endif %} + {% endwith %} +
+
+{% endblock %} diff --git a/openstack_dashboard/contrib/trove/content/database_clusters/templates/database_clusters/_detail_overview_vertica.html b/openstack_dashboard/contrib/trove/content/database_clusters/templates/database_clusters/_detail_overview_vertica.html new file mode 100644 index 0000000000..22d823acf6 --- /dev/null +++ b/openstack_dashboard/contrib/trove/content/database_clusters/templates/database_clusters/_detail_overview_vertica.html @@ -0,0 +1,29 @@ +{% load i18n sizeformat %} + +

{% trans "Cluster Overview" %}

+ +
+

{% trans "Information" %}

+
+
+
{% trans "Name" %}
+
{{ cluster.name }}
+
{% trans "ID" %}
+
{{ cluster.id }}
+
{% trans "Datastore" %}
+
{{ cluster.datastore.type }}
+
{% trans "Datastore Version" %}
+
{{ cluster.datastore.version }}
+
{% trans "Current Task" %}
+
{{ cluster.task.name|title }}
+
{% trans "RAM" %}
+
{{ cluster.full_flavor.ram|mbformat }}
+
{% trans "Number of Instances" %}
+
{{ cluster.num_instances }}
+
{% trans "Managment Console" %}
+
{{ cluster.mgmt_url }}
+
+
+ +{% block connection_info %} +{% endblock %} diff --git a/openstack_dashboard/contrib/trove/content/database_clusters/templates/database_clusters/_launch.html b/openstack_dashboard/contrib/trove/content/database_clusters/templates/database_clusters/_launch.html new file mode 100644 index 0000000000..3753b75b6a --- /dev/null +++ b/openstack_dashboard/contrib/trove/content/database_clusters/templates/database_clusters/_launch.html @@ -0,0 +1,22 @@ +{% extends "horizon/common/_modal_form.html" %} +{% load i18n %} +{% load url from future %} + +{% block form_id %}launch_form{% endblock %} +{% block form_action %}{% url "horizon:project:database_clusters:launch" %}{% endblock %} + +{% block modal_id %}launch_modal{% endblock %} +{% block modal-header %}{% trans "Launch Cluster" %}{% endblock %} + +{% block modal-body %} +
+
+ {% include "horizon/common/_form_fields.html" %} +
+
+{% endblock %} + +{% block modal-footer %} + + {% trans "Cancel" %} +{% endblock %} diff --git a/openstack_dashboard/contrib/trove/content/database_clusters/templates/database_clusters/_reset_password.html b/openstack_dashboard/contrib/trove/content/database_clusters/templates/database_clusters/_reset_password.html new file mode 100644 index 0000000000..9b27084784 --- /dev/null +++ b/openstack_dashboard/contrib/trove/content/database_clusters/templates/database_clusters/_reset_password.html @@ -0,0 +1,25 @@ +{% extends "horizon/common/_modal_form.html" %} +{% load i18n %} +{% load url from future %} + +{% block form_id %}reset_password_form{% endblock %} +{% block form_action %}{% url "horizon:project:database_clusters:reset_password" cluster_id %}{% endblock %} + +{% block modal_id %}reset_password_modal{% endblock %} +{% block modal-header %}{% trans "Reset Root Password" %}{% endblock %} + +{% block modal-body %} +
+
+ {% include "horizon/common/_form_fields.html" %} +
+
+
+

{% blocktrans %}Specify the new root password for vertica cluster.{% endblocktrans %}

+
+{% endblock %} + +{% block modal-footer %} + + {% trans "Cancel" %} +{% endblock %} \ No newline at end of file diff --git a/openstack_dashboard/contrib/trove/content/database_clusters/templates/database_clusters/add_shard.html b/openstack_dashboard/contrib/trove/content/database_clusters/templates/database_clusters/add_shard.html new file mode 100644 index 0000000000..ef9d23d332 --- /dev/null +++ b/openstack_dashboard/contrib/trove/content/database_clusters/templates/database_clusters/add_shard.html @@ -0,0 +1,7 @@ +{% 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/openstack_dashboard/contrib/trove/content/database_clusters/templates/database_clusters/detail.html b/openstack_dashboard/contrib/trove/content/database_clusters/templates/database_clusters/detail.html new file mode 100644 index 0000000000..6906b819a0 --- /dev/null +++ b/openstack_dashboard/contrib/trove/content/database_clusters/templates/database_clusters/detail.html @@ -0,0 +1,12 @@ +{% extends 'base.html' %} +{% load i18n sizeformat %} +{% block title %}{% trans "Cluster Detail" %}{% endblock %} + +{% block main %} +
+
+ {{ tab_group.render }} +
+
+{% endblock %} + diff --git a/openstack_dashboard/contrib/trove/content/database_clusters/templates/database_clusters/index.html b/openstack_dashboard/contrib/trove/content/database_clusters/templates/database_clusters/index.html new file mode 100644 index 0000000000..c0212f01bb --- /dev/null +++ b/openstack_dashboard/contrib/trove/content/database_clusters/templates/database_clusters/index.html @@ -0,0 +1,11 @@ +{% extends 'base.html' %} +{% load i18n %} +{% block title %}{% trans "Clusters" %}{% endblock %} + +{% block page_header %} + {% include "horizon/common/_page_header.html" with title=_("Clusters") %} +{% endblock page_header %} + +{% block main %} + {{ table.render }} +{% endblock %} diff --git a/openstack_dashboard/contrib/trove/content/database_clusters/templates/database_clusters/launch.html b/openstack_dashboard/contrib/trove/content/database_clusters/templates/database_clusters/launch.html new file mode 100644 index 0000000000..5fb645bf8e --- /dev/null +++ b/openstack_dashboard/contrib/trove/content/database_clusters/templates/database_clusters/launch.html @@ -0,0 +1,11 @@ +{% extends 'base.html' %} +{% load i18n %} +{% block title %}{% trans "Launch Cluster" %}{% endblock %} + +{% block page_header %} + {% include "horizon/common/_page_header.html" with title=_("Launch Cluster") %} +{% endblock page_header %} + +{% block main %} + {% include 'project/database_clusters/_launch.html' %} +{% endblock %} diff --git a/openstack_dashboard/contrib/trove/content/database_clusters/templates/database_clusters/reset_password.html b/openstack_dashboard/contrib/trove/content/database_clusters/templates/database_clusters/reset_password.html new file mode 100644 index 0000000000..cb90724b97 --- /dev/null +++ b/openstack_dashboard/contrib/trove/content/database_clusters/templates/database_clusters/reset_password.html @@ -0,0 +1,7 @@ +{% extends "base.html" %} +{% load i18n %} +{% block title %}{% trans "Reset Root Password" %}{% endblock %} + +{% block main %} + {% include "project/database_clusters/_reset_password.html" %} +{% endblock %} \ No newline at end of file diff --git a/openstack_dashboard/contrib/trove/content/database_clusters/tests.py b/openstack_dashboard/contrib/trove/content/database_clusters/tests.py new file mode 100644 index 0000000000..61ec0b6024 --- /dev/null +++ b/openstack_dashboard/contrib/trove/content/database_clusters/tests.py @@ -0,0 +1,295 @@ +# Copyright (c) 2014 eBay Software Foundation +# Copyright 2015 HP Software, LLC +# All Rights Reserved. +# +# 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.urlresolvers import reverse +from django import http + +from mox3.mox import IsA # noqa + +from openstack_dashboard import api +from openstack_dashboard.contrib.trove import api as trove_api +from openstack_dashboard.test import helpers as test + +from troveclient import common + + +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' + + +class ClustersTests(test.TestCase): + @test.create_stubs({trove_api.trove: ('cluster_list', + 'flavor_list')}) + def test_index(self): + clusters = common.Paginated(self.trove_clusters.list()) + trove_api.trove.cluster_list(IsA(http.HttpRequest), marker=None)\ + .AndReturn(clusters) + trove_api.trove.flavor_list(IsA(http.HttpRequest))\ + .AndReturn(self.flavors.list()) + + self.mox.ReplayAll() + res = self.client.get(INDEX_URL) + self.assertTemplateUsed(res, 'project/database_clusters/index.html') + + @test.create_stubs({trove_api.trove: ('cluster_list', + 'flavor_list')}) + def test_index_flavor_exception(self): + clusters = common.Paginated(self.trove_clusters.list()) + trove_api.trove.cluster_list(IsA(http.HttpRequest), marker=None)\ + .AndReturn(clusters) + trove_api.trove.flavor_list(IsA(http.HttpRequest))\ + .AndRaise(self.exceptions.trove) + + self.mox.ReplayAll() + res = self.client.get(INDEX_URL) + self.assertTemplateUsed(res, 'project/database_clusters/index.html') + self.assertMessageCount(res, error=1) + + @test.create_stubs({trove_api.trove: ('cluster_list',)}) + def test_index_list_exception(self): + trove_api.trove.cluster_list(IsA(http.HttpRequest), marker=None)\ + .AndRaise(self.exceptions.trove) + + self.mox.ReplayAll() + res = self.client.get(INDEX_URL) + self.assertTemplateUsed(res, 'project/database_clusters/index.html') + self.assertMessageCount(res, error=1) + + @test.create_stubs({trove_api.trove: ('cluster_list', + 'flavor_list')}) + def test_index_pagination(self): + clusters = self.trove_clusters.list() + last_record = clusters[0] + clusters = common.Paginated(clusters, next_marker="foo") + trove_api.trove.cluster_list(IsA(http.HttpRequest), marker=None)\ + .AndReturn(clusters) + trove_api.trove.flavor_list(IsA(http.HttpRequest))\ + .AndReturn(self.flavors.list()) + + self.mox.ReplayAll() + res = self.client.get(INDEX_URL) + self.assertTemplateUsed(res, 'project/database_clusters/index.html') + self.assertContains( + res, 'marker=' + last_record.id) + + @test.create_stubs({trove_api.trove: ('cluster_list', + 'flavor_list')}) + def test_index_flavor_list_exception(self): + clusters = common.Paginated(self.trove_clusters.list()) + trove_api.trove.cluster_list(IsA(http.HttpRequest), marker=None)\ + .AndReturn(clusters) + trove_api.trove.flavor_list(IsA(http.HttpRequest))\ + .AndRaise(self.exceptions.trove) + + self.mox.ReplayAll() + + res = self.client.get(INDEX_URL) + + self.assertTemplateUsed(res, 'project/database_clusters/index.html') + self.assertMessageCount(res, error=1) + + @test.create_stubs({trove_api.trove: ('datastore_flavors', + 'datastore_list', + 'datastore_version_list'), + api.base: ['is_service_enabled']}) + def test_launch_cluster(self): + api.base.is_service_enabled(IsA(http.HttpRequest), 'network')\ + .AndReturn(False) + trove_api.trove.datastore_flavors(IsA(http.HttpRequest), + 'mongodb', '2.6')\ + .AndReturn(self.flavors.list()) + trove_api.trove.datastore_list(IsA(http.HttpRequest))\ + .AndReturn(self.datastores.list()) + trove_api.trove.datastore_version_list(IsA(http.HttpRequest), + IsA(str))\ + .AndReturn(self.datastore_versions.list()) + self.mox.ReplayAll() + res = self.client.get(LAUNCH_URL) + self.assertTemplateUsed(res, 'project/database_clusters/launch.html') + + @test.create_stubs({trove_api.trove: ['datastore_flavors', + 'cluster_create', + 'datastore_list', + 'datastore_version_list'], + api.base: ['is_service_enabled']}) + def test_create_simple_cluster(self): + api.base.is_service_enabled(IsA(http.HttpRequest), 'network')\ + .AndReturn(False) + trove_api.trove.datastore_flavors(IsA(http.HttpRequest), + 'mongodb', '2.6')\ + .AndReturn(self.flavors.list()) + trove_api.trove.datastore_list(IsA(http.HttpRequest))\ + .AndReturn(self.datastores.list()) + trove_api.trove.datastore_version_list(IsA(http.HttpRequest), IsA(str))\ + .AndReturn(self.datastore_versions.list()) + + cluster_name = u'MyCluster' + cluster_volume = 1 + cluster_flavor = u'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' + cluster_instances = 3 + cluster_datastore = u'mongodb' + cluster_datastore_version = u'2.6' + cluster_network = u'' + trove_api.trove.cluster_create( + IsA(http.HttpRequest), + cluster_name, + cluster_volume, + cluster_flavor, + cluster_instances, + datastore=cluster_datastore, + datastore_version=cluster_datastore_version, + nics=cluster_network, + root_password=None).AndReturn(self.trove_clusters.first()) + + self.mox.ReplayAll() + post = { + 'name': cluster_name, + 'volume': cluster_volume, + 'num_instances': cluster_instances, + 'num_shards': 1, + 'num_instances_per_shards': cluster_instances, + 'datastore': cluster_datastore + u'-' + cluster_datastore_version, + 'mongodb_flavor': cluster_flavor, + 'network': cluster_network + } + + res = self.client.post(LAUNCH_URL, post) + self.assertNoFormErrors(res) + self.assertMessageCount(success=1) + + @test.create_stubs({trove_api.trove: ['datastore_flavors', + 'cluster_create', + 'datastore_list', + 'datastore_version_list'], + api.neutron: ['network_list_for_tenant'], + api.base: ['is_service_enabled']}) + def test_create_simple_cluster_neutron(self): + api.base.is_service_enabled(IsA(http.HttpRequest), 'network')\ + .AndReturn(True) + api.neutron.network_list_for_tenant(IsA(http.HttpRequest), '1')\ + .AndReturn(self.networks.list()) + trove_api.trove.datastore_flavors(IsA(http.HttpRequest), + 'mongodb', '2.6')\ + .AndReturn(self.flavors.list()) + trove_api.trove.datastore_list(IsA(http.HttpRequest))\ + .AndReturn(self.datastores.list()) + trove_api.trove.datastore_version_list(IsA(http.HttpRequest), IsA(str))\ + .AndReturn(self.datastore_versions.list()) + + cluster_name = u'MyCluster' + cluster_volume = 1 + cluster_flavor = u'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' + cluster_instances = 3 + cluster_datastore = u'mongodb' + cluster_datastore_version = u'2.6' + cluster_network = u'82288d84-e0a5-42ac-95be-e6af08727e42' + trove_api.trove.cluster_create( + IsA(http.HttpRequest), + cluster_name, + cluster_volume, + cluster_flavor, + cluster_instances, + datastore=cluster_datastore, + datastore_version=cluster_datastore_version, + nics=cluster_network, + root_password=None).AndReturn(self.trove_clusters.first()) + + self.mox.ReplayAll() + post = { + 'name': cluster_name, + 'volume': cluster_volume, + 'num_instances': cluster_instances, + 'num_shards': 1, + 'num_instances_per_shards': cluster_instances, + 'datastore': cluster_datastore + u'-' + cluster_datastore_version, + 'mongodb_flavor': cluster_flavor, + 'network': cluster_network + } + + res = self.client.post(LAUNCH_URL, post) + self.assertNoFormErrors(res) + self.assertMessageCount(success=1) + + @test.create_stubs({trove_api.trove: ['datastore_flavors', + 'cluster_create', + 'datastore_list', + 'datastore_version_list'], + api.neutron: ['network_list_for_tenant']}) + def test_create_simple_cluster_exception(self): + api.neutron.network_list_for_tenant(IsA(http.HttpRequest), '1')\ + .AndReturn(self.networks.list()) + trove_api.trove.datastore_flavors(IsA(http.HttpRequest), + 'mongodb', '2.6')\ + .AndReturn(self.flavors.list()) + trove_api.trove.datastore_list(IsA(http.HttpRequest))\ + .AndReturn(self.datastores.list()) + trove_api.trove.datastore_version_list(IsA(http.HttpRequest), IsA(str))\ + .AndReturn(self.datastore_versions.list()) + + cluster_name = u'MyCluster' + cluster_volume = 1 + cluster_flavor = u'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa' + cluster_instances = 3 + cluster_datastore = u'mongodb' + cluster_datastore_version = u'2.6' + cluster_network = u'82288d84-e0a5-42ac-95be-e6af08727e42' + trove_api.trove.cluster_create( + IsA(http.HttpRequest), + cluster_name, + cluster_volume, + cluster_flavor, + cluster_instances, + datastore=cluster_datastore, + datastore_version=cluster_datastore_version, + nics=cluster_network, + root_password=None).AndReturn(self.trove_clusters.first()) + + self.mox.ReplayAll() + post = { + 'name': cluster_name, + 'volume': cluster_volume, + 'num_instances': cluster_instances, + 'num_shards': 1, + 'num_instances_per_shards': cluster_instances, + 'datastore': cluster_datastore + u'-' + cluster_datastore_version, + 'mongodb_flavor': cluster_flavor, + 'network': cluster_network + } + + res = self.client.post(LAUNCH_URL, post) + self.assertRedirectsNoFollow(res, INDEX_URL) + + @test.create_stubs({trove_api.trove: ('cluster_get', + 'instance_get', + 'flavor_get',)}) + def test_details(self): + cluster = self.trove_clusters.first() + trove_api.trove.cluster_get(IsA(http.HttpRequest), cluster.id)\ + .MultipleTimes().AndReturn(cluster) + trove_api.trove.instance_get(IsA(http.HttpRequest), IsA(str))\ + .MultipleTimes().AndReturn(self.databases.first()) + trove_api.trove.flavor_get(IsA(http.HttpRequest), IsA(str))\ + .MultipleTimes().AndReturn(self.flavors.first()) + self.mox.ReplayAll() + + details_url = reverse('horizon:project:database_clusters:detail', + args=[cluster.id]) + res = self.client.get(details_url) + self.assertTemplateUsed(res, 'project/database_clusters/detail.html') + self.assertContains(res, cluster.ip[0]) diff --git a/openstack_dashboard/contrib/trove/content/database_clusters/urls.py b/openstack_dashboard/contrib/trove/content/database_clusters/urls.py new file mode 100644 index 0000000000..1962457680 --- /dev/null +++ b/openstack_dashboard/contrib/trove/content/database_clusters/urls.py @@ -0,0 +1,34 @@ +# Copyright (c) 2014 eBay Software Foundation +# Copyright 2015 HP Software, LLC +# All Rights Reserved. +# +# 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.conf.urls import patterns # noqa +from django.conf.urls import url # noqa + +from openstack_dashboard.contrib.trove.content.database_clusters import views + +CLUSTERS = r'^(?P[^/]+)/%s$' + +urlpatterns = patterns( + '', + url(r'^$', views.IndexView.as_view(), name='index'), + 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(), + name='reset_password'), +) diff --git a/openstack_dashboard/contrib/trove/content/database_clusters/views.py b/openstack_dashboard/contrib/trove/content/database_clusters/views.py new file mode 100644 index 0000000000..b66c2791da --- /dev/null +++ b/openstack_dashboard/contrib/trove/content/database_clusters/views.py @@ -0,0 +1,212 @@ +# Copyright (c) 2014 eBay Software Foundation +# Copyright 2015 HP Software, LLC +# All Rights Reserved. +# +# 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. + +""" +Views for managing database clusters. +""" +from collections import OrderedDict +import logging + +from django.core.urlresolvers import reverse +from django.core.urlresolvers import reverse_lazy +from django.utils.translation import ugettext_lazy as _ + +from horizon import exceptions +from horizon import forms as horizon_forms +from horizon import tables as horizon_tables +from horizon import tabs as horizon_tabs +from horizon.utils import memoized + +from openstack_dashboard.contrib.trove import api +from openstack_dashboard.contrib.trove.content.database_clusters import forms +from openstack_dashboard.contrib.trove.content.database_clusters import tables +from openstack_dashboard.contrib.trove.content.database_clusters import tabs + + +LOG = logging.getLogger(__name__) + + +class IndexView(horizon_tables.DataTableView): + table_class = tables.ClustersTable + template_name = 'project/database_clusters/index.html' + + def has_more_data(self, table): + return self._more + + @memoized.memoized_method + def get_flavors(self): + try: + flavors = api.trove.flavor_list(self.request) + except Exception: + flavors = [] + msg = _('Unable to retrieve database size information.') + exceptions.handle(self.request, msg) + return OrderedDict((unicode(flavor.id), flavor) for flavor in flavors) + + def _extra_data(self, cluster): + try: + cluster_flavor = cluster.instances[0]["flavor"]["id"] + flavors = self.get_flavors() + flavor = flavors.get(cluster_flavor) + if flavor is not None: + cluster.full_flavor = flavor + except Exception: + # ignore any errors and just return cluster unaltered + pass + return cluster + + def get_data(self): + marker = self.request.GET.get( + tables.ClustersTable._meta.pagination_param) + # Gather our clusters + try: + clusters = api.trove.cluster_list(self.request, marker=marker) + self._more = clusters.next or False + except Exception: + self._more = False + clusters = [] + msg = _('Unable to retrieve database clusters.') + exceptions.handle(self.request, msg) + + map(self._extra_data, clusters) + + return clusters + + +class LaunchClusterView(horizon_forms.ModalFormView): + form_class = forms.LaunchForm + template_name = 'project/database_clusters/launch.html' + success_url = reverse_lazy('horizon:project:database_clusters:index') + + +class DetailView(horizon_tabs.TabbedTableView): + tab_group_class = tabs.ClusterDetailTabs + template_name = 'project/database_clusters/detail.html' + + page_title = _("Cluster Details: {{ cluster.name }}") + + def get_context_data(self, **kwargs): + context = super(DetailView, self).get_context_data(**kwargs) + context["cluster"] = self.get_data() + return context + + @memoized.memoized_method + def get_data(self): + try: + cluster_id = self.kwargs['cluster_id'] + cluster = api.trove.cluster_get(self.request, cluster_id) + except Exception: + redirect = reverse('horizon:project:database_clusters:index') + msg = _('Unable to retrieve details ' + 'for database cluster: %s') % cluster_id + exceptions.handle(self.request, msg, redirect=redirect) + try: + cluster.full_flavor = api.trove.flavor_get( + self.request, cluster.instances[0]["flavor"]["id"]) + except Exception: + LOG.error('Unable to retrieve flavor details' + ' for database cluster: %s' % cluster_id) + cluster.num_instances = len(cluster.instances) + + # Todo(saurabhs) Set mgmt_url to dispaly Mgmt Console URL on + # cluster details page + # for instance in cluster.instances: + # if instance['type'] == "master": + # cluster.mgmt_url = "https://%s:5450/webui" % instance['ip'][0] + + return cluster + + def get_tabs(self, request, *args, **kwargs): + cluster = self.get_data() + 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") + + def get_context_data(self, **kwargs): + context = super(AddShardView, self).get_context_data(**kwargs) + context["cluster_id"] = self.kwargs['cluster_id'] + 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 + + 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 ResetPasswordView(horizon_forms.ModalFormView): + form_class = forms.ResetPasswordForm + template_name = 'project/database_clusters/reset_password.html' + success_url = reverse_lazy('horizon:project:database_clusters:index') + page_title = _("Reset Root Password") + + @memoized.memoized_method + def get_object(self, *args, **kwargs): + cluster_id = self.kwargs['cluster_id'] + try: + return api.trove.cluster_get(self.request, cluster_id) + except Exception: + msg = _('Unable to retrieve cluster details.') + redirect = reverse('horizon:project:database_clusters:index') + exceptions.handle(self.request, msg, redirect=redirect) + + def get_context_data(self, **kwargs): + context = super(ResetPasswordView, self).get_context_data(**kwargs) + context['cluster_id'] = self.kwargs['cluster_id'] + return context + + def get_initial(self): + return {'cluster_id': self.kwargs['cluster_id']} diff --git a/openstack_dashboard/contrib/trove/content/databases/db_capability.py b/openstack_dashboard/contrib/trove/content/databases/db_capability.py new file mode 100644 index 0000000000..8f63eb3879 --- /dev/null +++ b/openstack_dashboard/contrib/trove/content/databases/db_capability.py @@ -0,0 +1,25 @@ +# Copyright 2015 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. + + +MONGODB = "mongodb" +VERTICA = "vertica" + + +def is_mongodb_datastore(datastore): + return (datastore is not None) and (MONGODB in datastore.lower()) + + +def is_vertica_datastore(datastore): + return (datastore is not None) and (VERTICA in datastore.lower()) diff --git a/openstack_dashboard/contrib/trove/content/databases/templates/databases/_detail_overview_cassandra.html b/openstack_dashboard/contrib/trove/content/databases/templates/databases/_detail_overview_cassandra.html index 26dac1adaa..0b2f4d4d88 100644 --- a/openstack_dashboard/contrib/trove/content/databases/templates/databases/_detail_overview_cassandra.html +++ b/openstack_dashboard/contrib/trove/content/databases/templates/databases/_detail_overview_cassandra.html @@ -3,7 +3,7 @@ {% block connection_info %}
-

{% trans "Connection Info" %}

+

{% trans "Connection Information" %}


{% with instance.host as host %} @@ -20,4 +20,4 @@ {% endwith %}
-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/openstack_dashboard/contrib/trove/content/databases/templates/databases/_detail_overview_mongodb.html b/openstack_dashboard/contrib/trove/content/databases/templates/databases/_detail_overview_mongodb.html index 6c200ef147..28f3d222d4 100644 --- a/openstack_dashboard/contrib/trove/content/databases/templates/databases/_detail_overview_mongodb.html +++ b/openstack_dashboard/contrib/trove/content/databases/templates/databases/_detail_overview_mongodb.html @@ -3,22 +3,30 @@ {% block connection_info %}
-

{% trans "Connection Info" %}

+

{% trans "Connection Information" %}


- {% with instance.host as host %} -
{% trans "Host" %}
- {% if not host %} -
{% trans "Not Assigned" %}
- {% else %} -
{{ host }}
-
{% trans "Database Port" %}
-
27017
-
{% trans "Connection Examples" %}
-
mongo --host {{ host }}
-
mongodb://[{% trans "USERNAME" %}:{% trans "PASSWORD" %}@]{{ host }}:27017/{% trans "DATABASE" %}
- {% endif %} - {% endwith %} -
-
-{% endblock %} \ No newline at end of file + {% if instance.cluster_id %} + Link to Cluster Details for Connection Info + {% else %} +
+ {% with instance.host as host %} +
{% trans "Host" %}
+
+ {% if not host %} + {% trans "Not Assigned" %} +
+ {% else %} + {{ host }} + +
{% trans "Database Port" %}
+
27017
+
{% trans "Connection Examples" %}
+
mongo --host {{ host }}
+
mongodb://[{% trans "USERNAME" %}:{% trans "PASSWORD" %}@]{{ host }}:27017/{% trans "DATABASE" %}
+ {% endif %} + {% endwith %} +
+ {% endif %} + +{% endblock %} diff --git a/openstack_dashboard/contrib/trove/content/databases/tests.py b/openstack_dashboard/contrib/trove/content/databases/tests.py index e242fea793..52ff21b5ae 100644 --- a/openstack_dashboard/contrib/trove/content/databases/tests.py +++ b/openstack_dashboard/contrib/trove/content/databases/tests.py @@ -140,7 +140,7 @@ class DatabaseTests(test.TestCase): self.datastores.list()) # Mock datastore versions api.trove.datastore_version_list(IsA(http.HttpRequest), IsA(str)).\ - AndReturn(self.datastore_versions.list()) + MultipleTimes().AndReturn(self.datastore_versions.list()) dash_api.neutron.network_list(IsA(http.HttpRequest), tenant_id=self.tenant.id, @@ -207,7 +207,7 @@ class DatabaseTests(test.TestCase): # Mock datastore versions api.trove.datastore_version_list(IsA(http.HttpRequest), IsA(str))\ - .AndReturn(self.datastore_versions.list()) + .MultipleTimes().AndReturn(self.datastore_versions.list()) dash_api.neutron.network_list(IsA(http.HttpRequest), tenant_id=self.tenant.id, @@ -268,7 +268,7 @@ class DatabaseTests(test.TestCase): # Mock datastore versions api.trove.datastore_version_list(IsA(http.HttpRequest), IsA(str))\ - .AndReturn(self.datastore_versions.list()) + .MultipleTimes().AndReturn(self.datastore_versions.list()) dash_api.neutron.network_list(IsA(http.HttpRequest), tenant_id=self.tenant.id, @@ -499,7 +499,7 @@ class DatabaseTests(test.TestCase): api.trove.datastore_version_list(IsA(http.HttpRequest), IsA(str))\ - .AndReturn(self.datastore_versions.list()) + .MultipleTimes().AndReturn(self.datastore_versions.list()) dash_api.neutron.network_list(IsA(http.HttpRequest), tenant_id=self.tenant.id, diff --git a/openstack_dashboard/contrib/trove/content/databases/workflows/create_instance.py b/openstack_dashboard/contrib/trove/content/databases/workflows/create_instance.py index aebbc0c302..7912b5aa08 100644 --- a/openstack_dashboard/contrib/trove/content/databases/workflows/create_instance.py +++ b/openstack_dashboard/contrib/trove/content/databases/workflows/create_instance.py @@ -102,7 +102,7 @@ class SetInstanceDetailsAction(workflows.Action): num_datastores_with_one_version += 1 if num_datastores_with_one_version > 1: set_initial = True - if len(versions) > 0: + if versions: # only add to choices if datastore has at least one version version_choices = () for v in versions: diff --git a/openstack_dashboard/enabled/_1740_project_database_clusters_panel.py b/openstack_dashboard/enabled/_1740_project_database_clusters_panel.py new file mode 100644 index 0000000000..0aedc983a0 --- /dev/null +++ b/openstack_dashboard/enabled/_1740_project_database_clusters_panel.py @@ -0,0 +1,25 @@ +# Copyright [2015] Hewlett-Packard Development Company, L.P. +# All Rights Reserved. +# +# 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. + +# The slug of the panel to be added to HORIZON_CONFIG. Required. +PANEL = 'database_clusters' +# The slug of the dashboard the PANEL associated with. Required. +PANEL_DASHBOARD = 'project' +# The slug of the panel group the PANEL is associated with. +PANEL_GROUP = 'database' + +# Python panel class of the PANEL to be added. +ADD_PANEL = ('openstack_dashboard.contrib.trove.' + 'content.database_clusters.panel.Clusters') diff --git a/openstack_dashboard/test/test_data/trove_data.py b/openstack_dashboard/test/test_data/trove_data.py index a791f2acac..ed07d33165 100644 --- a/openstack_dashboard/test/test_data/trove_data.py +++ b/openstack_dashboard/test/test_data/trove_data.py @@ -1,4 +1,5 @@ # Copyright 2013 Rackspace Hosting. +# Copyright 2015 HP Software, LLC # # 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 @@ -13,6 +14,7 @@ # under the License. from troveclient.v1 import backups +from troveclient.v1 import clusters from troveclient.v1 import databases from troveclient.v1 import datastores from troveclient.v1 import flavors @@ -22,6 +24,104 @@ from troveclient.v1 import users from openstack_dashboard.test.test_data import utils +CLUSTER_DATA_ONE = { + "status": "ACTIVE", + "id": "dfbbd9ca-b5e1-4028-adb7-f78643e17998", + "name": "Test Cluster", + "created": "2014-04-25T20:19:23", + "updated": "2014-04-25T20:19:23", + "links": [], + "datastore": { + "type": "mongodb", + "version": "2.6" + }, + "ip": ["10.0.0.1"], + "instances": [ + { + "id": "416b0b16-ba55-4302-bbd3-ff566032e1c1", + "shard_id": "5415b62f-f301-4e84-ba90-8ab0734d15a7", + "flavor": { + "id": "7", + "links": [] + }, + "volume": { + "size": 100 + } + }, + { + "id": "965ef811-7c1d-47fc-89f2-a89dfdd23ef2", + "shard_id": "5415b62f-f301-4e84-ba90-8ab0734d15a7", + "flavor": { + "id": "7", + "links": [] + }, + "volume": { + "size": 100 + } + }, + { + "id": "3642f41c-e8ad-4164-a089-3891bf7f2d2b", + "shard_id": "5415b62f-f301-4e84-ba90-8ab0734d15a7", + "flavor": { + "id": "7", + "links": [] + }, + "volume": { + "size": 100 + } + } + ], + "task": { + "name": "test_task" + } +} + +CLUSTER_DATA_TWO = { + "status": "ACTIVE", + "id": "dfbbd9ca-b5e1-4028-adb7-f78643e17998", + "name": "Test Cluster", + "created": "2014-04-25T20:19:23", + "updated": "2014-04-25T20:19:23", + "links": [], + "datastore": { + "type": "vertica", + "version": "7.1" + }, + "ip": ["10.0.0.1"], + "instances": [ + { + "id": "416b0b16-ba55-4302-bbd3-ff566032e1c1", + "flavor": { + "id": "7", + "links": [] + }, + "volume": { + "size": 100 + } + }, + { + "id": "965ef811-7c1d-47fc-89f2-a89dfdd23ef2", + "flavor": { + "id": "7", + "links": [] + }, + "volume": { + "size": 100 + } + }, + { + "id": "3642f41c-e8ad-4164-a089-3891bf7f2d2b", + "flavor": { + "id": "7", + "links": [] + }, + "volume": { + "size": 100 + } + } + ] +} + DATABASE_DATA_ONE = { "status": "ACTIVE", "updated": "2013-08-12T22:00:09", @@ -130,6 +230,12 @@ DATASTORE_TWO = { "name": "mysql" } +DATASTORE_MONGODB = { + "id": "ccb31517-c472-409d-89b4-1a13db6bdd37", + "links": [], + "name": "mongodb" +} + VERSION_ONE = { "name": "5.5", "links": [], @@ -171,8 +277,22 @@ FLAVOR_THREE = { "name": "test.1" } +VERSION_MONGODB_2_6 = { + "name": "2.6", + "links": [], + "image": "c7956bb5-920e-4299-b68e-2347d830d937", + "active": 1, + "datastore": "ccb31517-c472-409d-89b4-1a13db6bdd37", + "packages": "2.6", + "id": "600a6d52-8347-4e00-8e4c-f4fa9cf96ae9" +} + def data(TEST): + cluster1 = clusters.Cluster(clusters.Clusters(None), + CLUSTER_DATA_ONE) + cluster2 = clusters.Cluster(clusters.Clusters(None), + CLUSTER_DATA_TWO) database1 = instances.Instance(instances.Instances(None), DATABASE_DATA_ONE) database2 = instances.Instance(instances.Instances(None), @@ -186,18 +306,22 @@ def data(TEST): datastore1 = datastores.Datastore(datastores.Datastores(None), DATASTORE_ONE) - version1 = datastores.\ DatastoreVersion(datastores.DatastoreVersions(None), VERSION_ONE) - version2 = datastores.\ - DatastoreVersion(datastores.DatastoreVersions(None), - VERSION_TWO) flavor1 = flavors.Flavor(flavors.Flavors(None), FLAVOR_ONE) flavor2 = flavors.Flavor(flavors.Flavors(None), FLAVOR_TWO) flavor3 = flavors.Flavor(flavors.Flavors(None), FLAVOR_THREE) + datastore_mongodb = datastores.Datastore(datastores.Datastores(None), + DATASTORE_MONGODB) + version_mongodb_2_6 = datastores.\ + DatastoreVersion(datastores.DatastoreVersions(None), + VERSION_MONGODB_2_6) + TEST.trove_clusters = utils.TestDataContainer() + TEST.trove_clusters.add(cluster1) + TEST.trove_clusters.add(cluster2) TEST.databases = utils.TestDataContainer() TEST.database_backups = utils.TestDataContainer() TEST.database_users = utils.TestDataContainer() @@ -213,7 +337,8 @@ def data(TEST): TEST.database_user_dbs.add(user_db1) TEST.datastores = utils.TestDataContainer() TEST.datastores.add(datastore1) - TEST.datastore_versions = utils.TestDataContainer() - TEST.datastore_versions.add(version1) - TEST.datastore_versions.add(version2) + TEST.datastores.add(datastore_mongodb) TEST.database_flavors.add(flavor1, flavor2, flavor3) + TEST.datastore_versions = utils.TestDataContainer() + TEST.datastore_versions.add(version_mongodb_2_6) + TEST.datastore_versions.add(version1)