diff --git a/requirements.txt b/requirements.txt index 4995392..3168fb9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,3 +7,4 @@ oslo.log>=3.30.0 # Apache-2.0 python-swiftclient>=2.2.0 python-troveclient>=1.2.0 horizon>=17.1.0 # Apache-2.0 +netaddr>=0.7.18 # BSD diff --git a/trove_dashboard/api/trove.py b/trove_dashboard/api/trove.py index ec23343..d200d68 100644 --- a/trove_dashboard/api/trove.py +++ b/trove_dashboard/api/trove.py @@ -149,7 +149,7 @@ def instance_create(request, name, volume, flavor=None, databases=None, datastore=None, datastore_version=None, replica_of=None, replica_count=None, volume_type=None, configuration=None, locality=None, - availability_zone=None): + availability_zone=None, access=None): # TODO(dklyle): adding conditional to support trove without volume # support for now until API supports checking for volume support if volume > 0 and not replica_of: @@ -179,7 +179,8 @@ def instance_create(request, name, volume, flavor=None, databases=None, replica_count=replica_count, configuration=configuration, locality=locality, - availability_zone=availability_zone) + availability_zone=availability_zone, + access=access) def instance_resize_volume(request, instance_id, size): diff --git a/trove_dashboard/content/database_backups/tests.py b/trove_dashboard/content/database_backups/tests.py index 597abd1..b69e4a5 100644 --- a/trove_dashboard/content/database_backups/tests.py +++ b/trove_dashboard/content/database_backups/tests.py @@ -214,7 +214,7 @@ class DatabasesBackupsTests(test.TestCase): url = RESTORE_URL + '?backup=%s' % self.database_backups.first().id res = self.client.get(url) self.assert_mock_multiple_calls_with_same_arguments( - self.mock_check, 4, mock.call((), test.IsHttpRequest())) + self.mock_check, 5, mock.call((), test.IsHttpRequest())) self.mock_backup_get.assert_called_once_with( test.IsHttpRequest(), test.IsA(str)) self.mock_backup_list.assert_called_once_with(test.IsHttpRequest()) diff --git a/trove_dashboard/content/databases/templates/databases/_launch_access_help.html b/trove_dashboard/content/databases/templates/databases/_launch_access_help.html new file mode 100644 index 0000000..07ae2ac --- /dev/null +++ b/trove_dashboard/content/databases/templates/databases/_launch_access_help.html @@ -0,0 +1,4 @@ +{% load i18n %} + +

{% blocktrans trimmed %}Access defines how the database service is exposed.{% endblocktrans %}

+

{% blocktrans trimmed %}CIDRs is a comma-separated list of IPv4, IPv6 or mix of both CIDRs that restrict access to the database service. 0.0.0.0/0 is used by default if this parameter is not provided. E.g.: 0.0.0.0/0,202.78.240.0/24{% endblocktrans %}

diff --git a/trove_dashboard/content/databases/tests.py b/trove_dashboard/content/databases/tests.py index 0c05563..6bafaae 100644 --- a/trove_dashboard/content/databases/tests.py +++ b/trove_dashboard/content/databases/tests.py @@ -163,7 +163,7 @@ class DatabaseTests(test.TestCase): res = self.client.get(LAUNCH_URL) self.assert_mock_multiple_calls_with_same_arguments( - self.mock_check, 4, mock.call((), test.IsHttpRequest())) + self.mock_check, 5, mock.call((), test.IsHttpRequest())) self.assert_mock_multiple_calls_with_same_arguments( self.mock_datastore_flavors, 20, mock.call(test.IsHttpRequest(), @@ -269,7 +269,7 @@ class DatabaseTests(test.TestCase): res = self.client.post(LAUNCH_URL, post) self.assert_mock_multiple_calls_with_same_arguments( - self.mock_check, 4, mock.call((), test.IsHttpRequest())) + self.mock_check, 5, mock.call((), test.IsHttpRequest())) self.assert_mock_multiple_calls_with_same_arguments( self.mock_datastore_flavors, 20, mock.call(test.IsHttpRequest(), @@ -306,7 +306,8 @@ class DatabaseTests(test.TestCase): replica_count=None, volume_type=None, locality=None, - availability_zone=test.IsA(str)) + availability_zone=test.IsA(str), + access=None) self.assertRedirectsNoFollow(res, INDEX_URL) @test.create_mocks({ @@ -359,7 +360,7 @@ class DatabaseTests(test.TestCase): res = self.client.post(LAUNCH_URL, post) self.assert_mock_multiple_calls_with_same_arguments( - self.mock_check, 4, mock.call((), test.IsHttpRequest())) + self.mock_check, 5, mock.call((), test.IsHttpRequest())) self.assert_mock_multiple_calls_with_same_arguments( self.mock_datastore_flavors, 20, mock.call(test.IsHttpRequest(), @@ -396,7 +397,8 @@ class DatabaseTests(test.TestCase): replica_count=None, volume_type=None, locality=None, - availability_zone=test.IsA(str)) + availability_zone=test.IsA(str), + access=None) self.assertRedirectsNoFollow(res, INDEX_URL) @test.create_mocks({ @@ -1093,7 +1095,7 @@ class DatabaseTests(test.TestCase): res = self.client.post(LAUNCH_URL, post) self.assert_mock_multiple_calls_with_same_arguments( - self.mock_check, 4, mock.call((), test.IsHttpRequest())) + self.mock_check, 5, mock.call((), test.IsHttpRequest())) self.assert_mock_multiple_calls_with_same_arguments( self.mock_datastore_flavors, 20, mock.call(test.IsHttpRequest(), @@ -1133,7 +1135,8 @@ class DatabaseTests(test.TestCase): replica_count=2, volume_type=None, locality=None, - availability_zone=test.IsA(str)) + availability_zone=test.IsA(str), + access=None) self.assertRedirectsNoFollow(res, INDEX_URL) @test.create_mocks({ diff --git a/trove_dashboard/content/databases/workflows/create_instance.py b/trove_dashboard/content/databases/workflows/create_instance.py index 4bca77d..437799c 100644 --- a/trove_dashboard/content/databases/workflows/create_instance.py +++ b/trove_dashboard/content/databases/workflows/create_instance.py @@ -15,6 +15,7 @@ from django.conf import settings from django.urls import reverse from django.utils.translation import ugettext_lazy as _ +import netaddr from horizon import exceptions from horizon import forms @@ -283,6 +284,44 @@ class SetInstanceDetails(workflows.Step): "locality", "availability_zone") +class AddAccessAction(workflows.Action): + """Initialize the database access. This tab will honor + the settings which should be a list of permissions required: + + * TROVE_ADD_USER_PERMS = [] + * TROVE_ADD_DATABASE_PERMS = [] + """ + is_public = forms.BooleanField(label=_("Is Public"), + required=False) + allowed_cidrs = forms.CharField(label=_("Allowed CIDRs"), + required=False, + help_text=_("Comma-separated CIDRs " + "to connect through.")) + + class Meta(object): + name = _("Database Access") + permissions = TROVE_ADD_PERMS + help_text_template = "project/databases/_launch_access_help.html" + + def clean(self): + cleaned_data = super(AddAccessAction, self).clean() + if cleaned_data.get('allowed_cidrs'): + cidrs = cleaned_data.get('allowed_cidrs').split(',') + for cidr in cidrs: + try: + netaddr.IPNetwork(cidr) + except netaddr.AddrFormatError: + msg = _('Invalid Allowed CIDR provided.') + self._errors["allowed_cidrs"] = self.error_class([msg]) + + return cleaned_data + + +class DatabaseAccess(workflows.Step): + action_class = AddAccessAction + contributes = ["is_public", "allowed_cidrs"] + + class AddDatabasesAction(workflows.Action): """Initialize the database with users/databases. This tab will honor the settings which should be a list of permissions required: @@ -513,6 +552,7 @@ class LaunchInstance(workflows.Workflow): success_url = "horizon:project:databases:index" default_steps = (SetInstanceDetails, dash_create_instance.SetNetwork, + DatabaseAccess, InitializeDatabase, Advanced) @@ -577,6 +617,16 @@ class LaunchInstance(workflows.Workflow): locality = context['locality'] return locality + def _get_access(self, context): + if not context['allowed_cidrs'] and not context['is_public']: + return None + access = {} + if context['allowed_cidrs'] != '': + access['allowed_cidrs'].split(',') + if context['is_public']: + access['is_public'] = True + return access + def handle(self, request, context): try: datastore, datastore_version = parse_datastore_and_version_text( @@ -597,6 +647,7 @@ class LaunchInstance(workflows.Workflow): context.get('master'), context['replica_count'], context.get('config'), self._get_locality(context), avail_zone) + api.trove.instance_create(request, context['name'], context['volume'], @@ -613,7 +664,8 @@ class LaunchInstance(workflows.Workflow): context), configuration=context.get('config'), locality=self._get_locality(context), - availability_zone=avail_zone) + availability_zone=avail_zone, + access=self._get_access(context)) return True except Exception: exceptions.handle(request)