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)