Add missing replication functionality

Add Horizon support for ejecting replica sources, promoting replicas
and specifying the replica count when launching instances based on
a master instance.

Added a promote row action to the instance table that is available
only when the instance is active and is a replica.
Added a confirmation dialog that displays information on the current
replica and replica source.

Added an eject row action to the instances table that is only
visible when the instance is a replica source.

Added an integer field to specify the number of replicas to start
on the advanced tab in the Launch instance dialog.  This field
appears only when a replica source is selected and has a minimum
value of 1.

Modified the trove create instance api to include the replica count
and new apis for eject and promote.

Change-Id: I67c2a91f6476ebe01c2b03ed529feac49411769b
Closes-Bug: #1551807
This commit is contained in:
Duk Loi 2016-03-01 11:56:39 -05:00
parent 62c3daac65
commit 44d750ecfb
10 changed files with 323 additions and 7 deletions

View File

@ -121,7 +121,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,
replica_of=None, volume_type=None):
replica_of=None, replica_count=None,
volume_type=None):
# TODO(dklyle): adding conditional to support trove without volume
# support for now until API supports checking for volume support
if volume > 0:
@ -140,7 +141,8 @@ def instance_create(request, name, volume, flavor, databases=None,
nics=nics,
datastore=datastore,
datastore_version=datastore_version,
replica_of=replica_of)
replica_of=replica_of,
replica_count=replica_count)
def instance_resize_volume(request, instance_id, size):
@ -165,6 +167,15 @@ def instance_detach_replica(request, instance_id):
detach_replica_source=True)
def promote_to_replica_source(request, instance_id):
return troveclient(request).instances.promote_to_replica_source(
instance_id)
def eject_replica_source(request, instance_id):
return troveclient(request).instances.eject_replica_source(instance_id)
def database_list(request, instance_id):
return troveclient(request).databases.list(instance_id)

View File

@ -122,6 +122,26 @@ class ResizeInstanceForm(forms.SelfHandlingForm):
return True
class PromoteToReplicaSourceForm(forms.SelfHandlingForm):
instance_id = forms.CharField(widget=forms.HiddenInput())
def handle(self, request, data):
instance_id = data.get('instance_id')
name = self.initial['replica'].name
try:
api.trove.promote_to_replica_source(request, instance_id)
messages.success(
request,
_('Promoted replica "%s" as the new replica source.') % name)
except Exception as e:
redirect = reverse("horizon:project:databases:index")
exceptions.handle(
request,
_('Unable to promote replica as the new replica source. "%s"')
% e.message, redirect=redirect)
return True
class CreateUserForm(forms.SelfHandlingForm):
instance_id = forms.CharField(widget=forms.HiddenInput())
name = forms.CharField(label=_("Name"))

View File

@ -122,6 +122,49 @@ class DetachReplica(tables.BatchAction):
api.trove.instance_detach_replica(request, obj_id)
class PromoteToReplicaSource(tables.LinkAction):
name = "promote_to_replica_source"
verbose_name = _("Promote to Replica Source")
url = "horizon:project:databases:promote_to_replica_source"
classes = ("ajax-modal", "btn-promote-to-replica-source")
def allowed(self, request, instance=None):
return (instance.status in ACTIVE_STATES
and hasattr(instance, 'replica_of'))
def get_link_url(self, datum):
instance_id = self.table.get_object_id(datum)
return urlresolvers.reverse(self.url, args=[instance_id])
class EjectReplicaSource(tables.BatchAction):
@staticmethod
def action_present(count):
return ungettext_lazy(
u"Eject Replica Source",
u"Eject Replica Sources",
count
)
@staticmethod
def action_past(count):
return ungettext_lazy(
u"Ejected Replica Source",
u"Ejected Replica Sources",
count
)
name = "eject_replica_source"
classes = ('btn-danger', 'btn-eject-replica-source')
def _allowed(self, request, instance=None):
return (instance.status != 'PROMOTE'
and hasattr(instance, 'replicas'))
def action(self, request, obj_id):
api.trove.eject_replica_source(request, obj_id)
class GrantAccess(tables.BatchAction):
@staticmethod
def action_present(count):
@ -590,9 +633,11 @@ class InstancesTable(tables.DataTable):
row_actions = (CreateBackup,
ResizeVolume,
ResizeInstance,
PromoteToReplicaSource,
ManageRoot,
RestartInstance,
EjectReplicaSource,
DetachReplica,
RestartInstance,
DeleteInstance)

View File

@ -0,0 +1,34 @@
{% extends "horizon/common/_modal_form.html" %}
{% load i18n %}
{% block modal-body %}
<div class="row">
<p class="col-md-12">{% trans 'Confirm the current replica is to be promoted as the new replica source.' %}</p>
<p class="col-md-12">{% trans 'This action cannot be undone.' %}</p>
<fieldset>
{% include "horizon/common/_form_fields.html" %}
</fieldset>
</div>
<div class="row">
<div class="left">
<div class="well">
<h4>{% trans "Current Replica" %}</h4>
<table class='table table-bordered'>
<tr><th>{% trans 'Name' %}</th><td>{{ replica.name|default:_("None") }}</td></tr>
<tr><th>{% trans 'Host' %}</th><td>{{ replica.ip|default:_("Unknown") }}</td></tr>
<tr><th>{% trans 'Status' %}</th><td>{{ replica.status|default:_("Unknown") }}</td></tr>
</table>
</div>
</div>
<div class="right">
<div class="well">
<h4>{% trans "Current Replica Source" %}</h4>
<table class='table table-bordered'>
<tr><th>{% trans 'Name' %}</th><td>{{ replica_source.name|default:_("None") }}</td></tr>
<tr><th>{% trans 'Host' %}</th><td>{{ replica_source.ip|default:_("Unknown") }}</td></tr>
<tr><th>{% trans 'Status' %}</th><td>{{ replica_source.status|default:_("Unknown") }}</td></tr>
</table>
</div>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,5 @@
{% extends "base.html" %}
{% block main %}
{% include "project/databases/_promote_to_replica_source.html" %}
{% endblock %}

View File

@ -90,7 +90,7 @@ class DatabaseTests(test.TestCase):
def test_index_pagination(self):
# Mock database instances
databases = self.databases.list()
last_record = databases[1]
last_record = databases[-1]
databases = common.Paginated(databases, next_marker="foo")
api.trove.instance_list(IsA(http.HttpRequest), marker=None)\
.AndReturn(databases)
@ -241,6 +241,7 @@ class DatabaseTests(test.TestCase):
replica_of=None,
users=None,
nics=nics,
replica_count=None,
volume_type=None).AndReturn(self.databases.first())
self.mox.ReplayAll()
@ -307,6 +308,7 @@ class DatabaseTests(test.TestCase):
replica_of=None,
users=None,
nics=nics,
replica_count=None,
volume_type=None).AndRaise(trove_exception)
self.mox.ReplayAll()
@ -1000,6 +1002,7 @@ class DatabaseTests(test.TestCase):
replica_of=self.databases.first().id,
users=None,
nics=nics,
replica_count=2,
volume_type=None).AndReturn(self.databases.first())
self.mox.ReplayAll()
@ -1011,8 +1014,107 @@ class DatabaseTests(test.TestCase):
'datastore': 'mysql,5.5',
'initial_state': 'master',
'master': self.databases.first().id,
'replica_count': 2,
'volume_type': 'no_type'
}
res = self.client.post(LAUNCH_URL, post)
self.assertRedirectsNoFollow(res, INDEX_URL)
@test.create_stubs({
api.trove: ('promote_to_replica_source',),
views.PromoteToReplicaSourceView: ('get_initial',)})
def test_promote_replica_instance(self):
replica_source = self.databases.first()
replica = self.databases.list()[1]
initial = {'instance_id': replica_source.id,
'replica': replica,
'replica_source': replica_source}
views.PromoteToReplicaSourceView.get_initial().AndReturn(initial)
api.trove.promote_to_replica_source(
IsA(http.HttpRequest), replica_source.id)
self.mox.ReplayAll()
url = reverse('horizon:project:databases:promote_to_replica_source',
args=[replica_source.id])
form = {
'instance_id': replica_source.id
}
res = self.client.post(url, form)
self.assertNoFormErrors(res)
self.assertRedirectsNoFollow(res, INDEX_URL)
@test.create_stubs({
api.trove: ('promote_to_replica_source',),
views.PromoteToReplicaSourceView: ('get_initial',)})
def test_promote_replica_instance_exception(self):
replica_source = self.databases.first()
replica = self.databases.list()[1]
initial = {'instance_id': replica_source.id,
'replica': replica,
'replica_source': replica_source}
views.PromoteToReplicaSourceView.get_initial().AndReturn(initial)
api.trove.promote_to_replica_source(
IsA(http.HttpRequest), replica_source.id).\
AndRaise(self.exceptions.trove)
self.mox.ReplayAll()
url = reverse('horizon:project:databases:promote_to_replica_source',
args=[replica_source.id])
form = {
'instance_id': replica_source.id
}
res = self.client.post(url, form)
self.assertEqual(res.status_code, 302)
self.assertRedirectsNoFollow(res, INDEX_URL)
@test.create_stubs({
api.trove: ('instance_list',
'eject_replica_source',),
})
def test_eject_replica_source(self):
databases = common.Paginated(self.databases.list())
database = databases[2]
api.trove.eject_replica_source(
IsA(http.HttpRequest), database.id)
databases = common.Paginated(self.databases.list())
api.trove.instance_list(IsA(http.HttpRequest), marker=None)\
.AndReturn(databases)
self.mox.ReplayAll()
res = self.client.post(
INDEX_URL,
{'action': 'databases__eject_replica_source__%s' % database.id})
self.assertRedirectsNoFollow(res, INDEX_URL)
@test.create_stubs({
api.trove: ('instance_list',
'eject_replica_source',),
})
def test_eject_replica_source_exception(self):
databases = common.Paginated(self.databases.list())
database = databases[2]
api.trove.eject_replica_source(
IsA(http.HttpRequest), database.id)\
.AndRaise(self.exceptions.trove)
databases = common.Paginated(self.databases.list())
api.trove.instance_list(IsA(http.HttpRequest), marker=None)\
.AndReturn(databases)
self.mox.ReplayAll()
res = self.client.post(
INDEX_URL,
{'action': 'databases__eject_replica_source__%s' % database.id})
self.assertRedirectsNoFollow(res, INDEX_URL)

View File

@ -39,6 +39,9 @@ urlpatterns = patterns(
name='access_detail'),
url(INSTANCES % 'create_database', views.CreateDatabaseView.as_view(),
name='create_database'),
url(INSTANCES % 'promote_to_replica_source',
views.PromoteToReplicaSourceView.as_view(),
name='promote_to_replica_source'),
url(INSTANCES % 'manage_root', views.ManageRootView.as_view(),
name='manage_root'),
)

View File

@ -357,6 +357,52 @@ class ResizeInstanceView(horizon_forms.ModalFormView):
return initial
class PromoteToReplicaSourceView(horizon_forms.ModalFormView):
form_class = forms.PromoteToReplicaSourceForm
form_id = "promote_to_replica_source_form"
modal_header = _("Promote to Replica Source")
modal_id = "promote_to_replica_source_modal"
template_name = 'project/databases/promote_to_replica_source.html'
submit_lable = _("Promote")
submit_url = 'horizon:project:databases:promote_to_replica_source'
success_url = reverse_lazy('horizon:project:databases:index')
@memoized.memoized_method
def get_object(self, *args, **kwargs):
instance_id = self.kwargs['instance_id']
try:
replica = api.trove.instance_get(self.request, instance_id)
replica_source = api.trove.instance_get(self.request,
replica.replica_of['id'])
instances = {'replica': replica,
'replica_source': replica_source}
return instances
except Exception:
msg = _('Unable to retrieve instance details.')
redirect = reverse('horizon:project:databases:index')
exceptions.handle(self.request, msg, redirect=redirect)
def get_context_data(self, **kwargs):
context = \
super(PromoteToReplicaSourceView, self).get_context_data(**kwargs)
context['instance_id'] = self.kwargs['instance_id']
context['replica'] = self.get_initial().get('replica')
context['replica'].ip = \
self.get_initial().get('replica').ip[0]
context['replica_source'] = self.get_initial().get('replica_source')
context['replica_source'].ip = \
self.get_initial().get('replica_source').ip[0]
args = (self.kwargs['instance_id'],)
context['submit_url'] = reverse(self.submit_url, args=args)
return context
def get_initial(self):
instances = self.get_object()
return {'instance_id': self.kwargs['instance_id'],
'replica': instances['replica'],
'replica_source': instances['replica_source']}
class EnableRootInfo(object):
def __init__(self, instance_id, instance_name, enabled, password=None):
self.id = instance_id

View File

@ -270,6 +270,17 @@ class AdvancedAction(workflows.Action):
'data-switch-on': 'initial_state',
'data-initial_state-master': _('Master Instance Name')
}))
replica_count = forms.IntegerField(
label=_('Replica Count'),
required=False,
min_value=1,
initial=1,
help_text=_('Specify the number of replicas to be created'),
widget=forms.TextInput(attrs={
'class': 'switched',
'data-switch-on': 'initial_state',
'data-initial_state-master': _('Replica Count')
}))
class Meta(object):
name = _("Advanced")
@ -309,6 +320,7 @@ class AdvancedAction(workflows.Action):
initial_state = cleaned_data.get("initial_state")
if initial_state == 'backup':
cleaned_data['replica_count'] = None
backup = self.cleaned_data['backup']
if backup:
try:
@ -336,13 +348,14 @@ class AdvancedAction(workflows.Action):
else:
cleaned_data['master'] = None
cleaned_data['backup'] = None
cleaned_data['replica_count'] = None
return cleaned_data
class Advanced(workflows.Step):
action_class = AdvancedAction
contributes = ['backup', 'master']
contributes = ['backup', 'master', 'replica_count']
class LaunchInstance(workflows.Workflow):
@ -417,13 +430,13 @@ class LaunchInstance(workflows.Workflow):
"{name=%s, volume=%s, volume_type=%s, flavor=%s, "
"datastore=%s, datastore_version=%s, "
"dbs=%s, users=%s, "
"backups=%s, nics=%s, replica_of=%s}",
"backups=%s, nics=%s, replica_of=%s replica_count=%s}",
context['name'], context['volume'],
self._get_volume_type(context), context['flavor'],
datastore, datastore_version,
self._get_databases(context), self._get_users(context),
self._get_backup(context), self._get_nics(context),
context.get('master'))
context.get('master'), context['replica_count'])
api.trove.instance_create(request,
context['name'],
context['volume'],
@ -435,6 +448,7 @@ class LaunchInstance(workflows.Workflow):
restore_point=self._get_backup(context),
nics=self._get_nics(context),
replica_of=context.get('master'),
replica_count=context['replica_count'],
volume_type=self._get_volume_type(
context))
return True

View File

@ -180,6 +180,39 @@ DATABASE_DATA_TWO = {
"id": "4d7b3f57-44f5-41d2-8e86-36b88cad572a",
}
DATABASE_DATA_THREE = {
"status": "ACTIVE",
"updated": "2015-01-12T22:00:09",
"name": "Test Database with Config",
"links": [],
"created": "2015-01-12T22:00:03",
"ip": [
"10.0.0.3",
],
"volume": {
"used": 0.13,
"size": 1,
},
"flavor": {
"id": "1",
"links": [],
},
"datastore": {
"type": "mysql",
"version": "5.5"
},
"id": "c3369597-b53a-4bd4-bf54-41957c1291b8",
"configuration": {
"id": "0ef978d3-7c83-4192-ab86-b7a0a5010fa0",
"links": [],
"name": "config1"
},
"replicas": {
"id": "0ef978d3-7c83-4192-ab86-b7a0a5010fa0",
"links": [],
}
}
BACKUP_ONE = {
"instance_id": "6ddc36d9-73db-4e23-b52e-368937d72719",
"status": "COMPLETED",
@ -345,6 +378,8 @@ def data(TEST):
DATABASE_DATA_ONE)
database2 = instances.Instance(instances.Instances(None),
DATABASE_DATA_TWO)
database3 = instances.Instance(instances.Instances(None),
DATABASE_DATA_THREE)
bkup1 = backups.Backup(backups.Backups(None), BACKUP_ONE)
bkup2 = backups.Backup(backups.Backups(None), BACKUP_TWO)
bkup3 = backups.Backup(backups.Backups(None), BACKUP_TWO_INC)
@ -391,6 +426,7 @@ def data(TEST):
TEST.databases.add(database1)
TEST.databases.add(database2)
TEST.databases.add(database3)
TEST.database_backups.add(bkup1)
TEST.database_backups.add(bkup2)
TEST.database_backups.add(bkup3)