Add support for Trove Replication

Added initial support for replication.
Changed launch dialog to have an Advanced tab where
you can select a master instance for replication
Display replication information in details view
Added support for Detach

Change-Id: I2c90d5be0f47c07fe9049a685fbf7a36a2585e0e
Implements: blueprint trove-replication-v1-support
This commit is contained in:
Andrew Bramley 2014-08-12 16:06:59 -04:00
parent 9d32c18ac8
commit d719e8e9f6
10 changed files with 261 additions and 46 deletions

View File

@ -57,7 +57,8 @@ def instance_delete(request, instance_id):
def instance_create(request, name, volume, flavor, databases=None,
users=None, restore_point=None, nics=None,
datastore=None, datastore_version=None):
datastore=None, datastore_version=None,
replica_of=None):
# TODO(dklyle): adding conditional to support trove without volume
# support for now until API supports checking for volume support
if volume > 0:
@ -73,7 +74,8 @@ def instance_create(request, name, volume, flavor, databases=None,
restorePoint=restore_point,
nics=nics,
datastore=datastore,
datastore_version=datastore_version)
datastore_version=datastore_version,
replica_of=replica_of)
def instance_resize_volume(request, instance_id, size):
@ -93,6 +95,11 @@ def instance_restart(request, instance_id):
return troveclient(request).instances.restart(instance_id)
def instance_detach_replica(request, instance_id):
return troveclient(request).instances.edit(instance_id,
detach_replica_source=True)
def database_list(request, instance_id):
return troveclient(request).databases.list(instance_id)

View File

@ -83,6 +83,34 @@ class RestartInstance(tables.BatchAction):
api.trove.instance_restart(request, obj_id)
class DetachReplica(tables.BatchAction):
@staticmethod
def action_present(count):
return ungettext_lazy(
u"Detach Replica",
u"Detach Replicas",
count
)
@staticmethod
def action_past(count):
return ungettext_lazy(
u"Replica Detached",
u"Replicas Detached",
count
)
name = "detach_replica"
classes = ('btn-danger', 'btn-detach-replica')
def allowed(self, request, instance=None):
return (instance.status in ACTIVE_STATES
and hasattr(instance, 'replica_of'))
def action(self, request, obj_id):
api.trove.instance_detach_replica(request, obj_id)
class DeleteUser(tables.DeleteAction):
@staticmethod
def action_present(count):
@ -289,6 +317,7 @@ class InstancesTable(tables.DataTable):
ResizeVolume,
ResizeInstance,
RestartInstance,
DetachReplica,
TerminateInstance)

View File

@ -1,4 +1,5 @@
{% load i18n sizeformat %}
{% load url from future %}
<h3>{% trans "Instance Overview" %}</h3>
@ -40,3 +41,29 @@
{% block connection_info %}
{% endblock %}
{% if instance.replica_of or instance.replicas %}
<div class="specs row detail">
<h4>{% trans "Replication" %}</h4>
<hr class="header_rule">
<dl>
{% if instance.replica_of %}
<dt>{% trans "Is a Replica Of" %}</dt>
<dd>
{% url 'horizon:project:databases:detail' instance.replica_of.id as instance_url %}
<a href="{{ instance_url }}">{{ instance.replica_of.id }}</a>
</dd>
{% endif %}
{% if instance.replicas %}
<dt>{% trans "Replicas" %}</dt>
{% for replica in instance.replicas %}
<dd>
{% url 'horizon:project:databases:detail' replica.id as instance_url %}
<a href="{{ instance_url }}">{{ replica.id }}</a>
</dd>
{% endfor %}
{% endif %}
</dl>
</div>
{% endif %}

View File

@ -0,0 +1,3 @@
{% load i18n %}
<p>{% blocktrans %}Optionally choose to create this database using a previous backup, or as a replica of another database instance.{% endblocktrans %}</p>

View File

@ -1,4 +1,4 @@
{% load i18n horizon humanize %}
{% load i18n %}
<p>{% blocktrans %}Specify the details for launching an instance.{% endblocktrans %}</p>
<p>{% blocktrans %}<strong>Please note:</strong> The value specified in the Volume Size field should be greater than 0, however, some configurations do not support specifying volume size. If specifying the volume size results in an error stating volume support is not enabled, enter 0.{% endblocktrans %}</p>

View File

@ -1,4 +1,4 @@
{% load i18n horizon humanize %}
{% load i18n %}
<h4>{% trans "Initial Databases" %}</h4>
<p>{% trans "Optionally provide a comma separated list of databases to create:" %}</p>

View File

@ -1,4 +1,4 @@
{% load i18n horizon %}
{% load i18n %}
<p>
{% blocktrans %}

View File

@ -1,4 +0,0 @@
{% load i18n horizon humanize %}
<p>{% blocktrans %}Create this database from a previous backup.{% endblocktrans %}</p>

View File

@ -121,18 +121,32 @@ class DatabaseTests(test.TestCase):
@test.create_stubs({
api.trove: ('flavor_list', 'backup_list',
'datastore_list', 'datastore_version_list')})
'datastore_list', 'datastore_version_list',
'instance_list'),
api.neutron: ('network_list',)})
def test_launch_instance(self):
api.trove.flavor_list(IsA(http.HttpRequest))\
.AndReturn(self.flavors.list())
api.trove.backup_list(IsA(http.HttpRequest))\
.AndReturn(self.database_backups.list())
api.trove.flavor_list(IsA(http.HttpRequest)).AndReturn(
self.flavors.list())
api.trove.backup_list(IsA(http.HttpRequest)).AndReturn(
self.database_backups.list())
api.trove.instance_list(IsA(http.HttpRequest)).AndReturn(
self.databases.list())
# Mock datastores
api.trove.datastore_list(IsA(http.HttpRequest))\
.AndReturn(self.datastores.list())
api.trove.datastore_list(IsA(http.HttpRequest)).AndReturn(
self.datastores.list())
# Mock datastore versions
api.trove.datastore_version_list(IsA(http.HttpRequest), IsA(str))\
.AndReturn(self.datastore_versions.list())
api.trove.datastore_version_list(IsA(http.HttpRequest), IsA(str)).\
AndReturn(self.datastore_versions.list())
api.neutron.network_list(IsA(http.HttpRequest),
tenant_id=self.tenant.id,
shared=False).AndReturn(
self.networks.list()[:1])
api.neutron.network_list(IsA(http.HttpRequest),
shared=True).AndReturn(
self.networks.list()[1:])
self.mox.ReplayAll()
res = self.client.get(LAUNCH_URL)
self.assertTemplateUsed(res, 'project/databases/launch.html')
@ -165,7 +179,8 @@ class DatabaseTests(test.TestCase):
@test.create_stubs({
api.trove: ('flavor_list', 'backup_list', 'instance_create',
'datastore_list', 'datastore_version_list'),
'datastore_list', 'datastore_version_list',
'instance_list'),
api.neutron: ('network_list',)})
def test_create_simple_instance(self):
api.trove.flavor_list(IsA(http.HttpRequest)).AndReturn(
@ -174,6 +189,9 @@ class DatabaseTests(test.TestCase):
api.trove.backup_list(IsA(http.HttpRequest)).AndReturn(
self.database_backups.list())
api.trove.instance_list(IsA(http.HttpRequest)).AndReturn(
self.databases.list())
# Mock datastores
api.trove.datastore_list(IsA(http.HttpRequest))\
.AndReturn(self.datastores.list())
@ -203,6 +221,7 @@ class DatabaseTests(test.TestCase):
datastore=IsA(unicode),
datastore_version=IsA(unicode),
restore_point=None,
replica_of=None,
users=None,
nics=nics).AndReturn(self.databases.first())
@ -220,7 +239,8 @@ class DatabaseTests(test.TestCase):
@test.create_stubs({
api.trove: ('flavor_list', 'backup_list', 'instance_create',
'datastore_list', 'datastore_version_list'),
'datastore_list', 'datastore_version_list',
'instance_list'),
api.neutron: ('network_list',)})
def test_create_simple_instance_exception(self):
trove_exception = self.exceptions.nova
@ -230,6 +250,9 @@ class DatabaseTests(test.TestCase):
api.trove.backup_list(IsA(http.HttpRequest)).AndReturn(
self.database_backups.list())
api.trove.instance_list(IsA(http.HttpRequest)).AndReturn(
self.databases.list())
# Mock datastores
api.trove.datastore_list(IsA(http.HttpRequest))\
.AndReturn(self.datastores.list())
@ -259,6 +282,7 @@ class DatabaseTests(test.TestCase):
datastore=IsA(unicode),
datastore_version=IsA(unicode),
restore_point=None,
replica_of=None,
users=None,
nics=nics).AndRaise(trove_exception)
@ -445,3 +469,67 @@ class DatabaseTests(test.TestCase):
res = self.client.post(url, post)
self.assertNoFormErrors(res)
self.assertRedirectsNoFollow(res, INDEX_URL)
@test.create_stubs({
api.trove: ('flavor_list', 'backup_list', 'instance_create',
'datastore_list', 'datastore_version_list',
'instance_list', 'instance_get'),
api.neutron: ('network_list',)})
def test_create_replica_instance(self):
api.trove.flavor_list(IsA(http.HttpRequest)).AndReturn(
self.flavors.list())
api.trove.backup_list(IsA(http.HttpRequest)).AndReturn(
self.database_backups.list())
api.trove.instance_list(IsA(http.HttpRequest)).AndReturn(
self.databases.list())
api.trove.datastore_list(IsA(http.HttpRequest))\
.AndReturn(self.datastores.list())
api.trove.datastore_version_list(IsA(http.HttpRequest),
IsA(str))\
.AndReturn(self.datastore_versions.list())
api.neutron.network_list(IsA(http.HttpRequest),
tenant_id=self.tenant.id,
shared=False).\
AndReturn(self.networks.list()[:1])
api.neutron.network_list(IsA(http.HttpRequest),
shared=True).\
AndReturn(self.networks.list()[1:])
nics = [{"net-id": self.networks.first().id, "v4-fixed-ip": ''}]
api.trove.instance_get(IsA(http.HttpRequest), IsA(unicode))\
.AndReturn(self.databases.first())
# Actual create database call
api.trove.instance_create(
IsA(http.HttpRequest),
IsA(unicode),
IsA(int),
IsA(unicode),
databases=None,
datastore=IsA(unicode),
datastore_version=IsA(unicode),
restore_point=None,
replica_of=self.databases.first().id,
users=None,
nics=nics).AndReturn(self.databases.first())
self.mox.ReplayAll()
post = {
'name': "MyDB",
'volume': '1',
'flavor': 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
'network': self.networks.first().id,
'datastore': 'mysql,5.5',
'initial_state': 'master',
'master': self.databases.first().id
}
res = self.client.post(LAUNCH_URL, post)
self.assertRedirectsNoFollow(res, INDEX_URL)

View File

@ -223,15 +223,41 @@ class InitializeDatabase(workflows.Step):
contributes = ["databases", 'user', 'password', 'host']
class RestoreAction(workflows.Action):
backup = forms.ChoiceField(label=_("Backup"),
required=False,
help_text=_('Select a backup to restore'))
class AdvancedAction(workflows.Action):
initial_state = forms.ChoiceField(
label=_('Source for Initial State'),
required=False,
help_text=_("Choose initial state."),
choices=[
('', _('None')),
('backup', _('Restore from Backup')),
('master', _('Replicate from Instance'))],
widget=forms.Select(attrs={
'class': 'switchable',
'data-slug': 'initial_state'
}))
backup = forms.ChoiceField(
label=_('Backup Name'),
required=False,
help_text=_('Select a backup to restore'),
widget=forms.Select(attrs={
'class': 'switched',
'data-switch-on': 'initial_state',
'data-initial_state-backup': _('Backup Name')
}))
master = forms.ChoiceField(
label=_('Master Instance Name'),
required=False,
help_text=_('Select a master instance'),
widget=forms.Select(attrs={
'class': 'switched',
'data-switch-on': 'initial_state',
'data-initial_state-master': _('Master Instance Name')
}))
class Meta(object):
name = _("Restore From Backup")
permissions = ('openstack.services.object-store',)
help_text_template = "project/databases/_launch_restore_help.html"
name = _("Advanced")
help_text_template = "project/databases/_launch_advanced_help.html"
def populate_backup_choices(self, request, context):
try:
@ -247,23 +273,60 @@ class RestoreAction(workflows.Action):
choices.insert(0, ("", _("No backups available")))
return choices
def clean_backup(self):
backup = self.cleaned_data['backup']
if backup:
try:
# Make sure the user is not "hacking" the form
# and that they have access to this backup_id
LOG.debug("Obtaining backups")
bkup = api.trove.backup_get(self.request, backup)
self.cleaned_data['backup'] = bkup.id
except Exception:
raise forms.ValidationError(_("Unable to find backup!"))
return backup
def populate_master_choices(self, request, context):
try:
instances = api.trove.instance_list(request)
choices = [(i.id, i.name) for i in
instances if i.status == 'ACTIVE']
except Exception:
choices = []
if choices:
choices.insert(0, ("", _("Select instance")))
else:
choices.insert(0, ("", _("No instances available")))
return choices
def clean(self):
cleaned_data = super(AdvancedAction, self).clean()
initial_state = cleaned_data.get("initial_state")
if initial_state == 'backup':
backup = self.cleaned_data['backup']
if backup:
try:
bkup = api.trove.backup_get(self.request, backup)
self.cleaned_data['backup'] = bkup.id
except Exception:
raise forms.ValidationError(_("Unable to find backup!"))
else:
raise forms.ValidationError(_("A backup must be selected!"))
cleaned_data['master'] = None
elif initial_state == 'master':
master = self.cleaned_data['master']
if master:
try:
api.trove.instance_get(self.request, master)
except Exception:
raise forms.ValidationError(
_("Unable to find master instance!"))
else:
raise forms.ValidationError(
_("A master instance must be selected!"))
cleaned_data['backup'] = None
else:
cleaned_data['master'] = None
cleaned_data['backup'] = None
return cleaned_data
class RestoreBackup(workflows.Step):
action_class = RestoreAction
contributes = ['backup']
class Advanced(workflows.Step):
action_class = AdvancedAction
contributes = ['backup', 'master']
class LaunchInstance(workflows.Workflow):
@ -276,7 +339,7 @@ class LaunchInstance(workflows.Workflow):
default_steps = (SetInstanceDetails,
SetNetwork,
InitializeDatabase,
RestoreBackup)
Advanced)
def __init__(self, request=None, context_seed=None, entry_point=None,
*args, **kwargs):
@ -332,11 +395,12 @@ class LaunchInstance(workflows.Workflow):
"{name=%s, volume=%s, flavor=%s, "
"datastore=%s, datastore_version=%s, "
"dbs=%s, users=%s, "
"backups=%s, nics=%s}",
"backups=%s, nics=%s, replica_of=%s}",
context['name'], context['volume'], context['flavor'],
datastore, datastore_version,
self._get_databases(context), self._get_users(context),
self._get_backup(context), self._get_nics(context))
self._get_backup(context), self._get_nics(context),
context.get('master'))
api.trove.instance_create(request,
context['name'],
context['volume'],
@ -346,7 +410,8 @@ class LaunchInstance(workflows.Workflow):
databases=self._get_databases(context),
users=self._get_users(context),
restore_point=self._get_backup(context),
nics=self._get_nics(context))
nics=self._get_nics(context),
replica_of=context.get('master'))
return True
except Exception:
exceptions.handle(request)