diff --git a/doc/v3/api_samples/os-server-groups/server-groups-get-resp.json b/doc/v3/api_samples/os-server-groups/server-groups-get-resp.json new file mode 100644 index 000000000000..f26cc8824e1c --- /dev/null +++ b/doc/v3/api_samples/os-server-groups/server-groups-get-resp.json @@ -0,0 +1,9 @@ +{ + "server_group": { + "id": "5bbcc3c4-1da2-4437-a48a-66f15b1b13f9", + "name": "test", + "policies": ["anti-affinity"], + "members": [], + "metadata": {} + } +} diff --git a/doc/v3/api_samples/os-server-groups/server-groups-list-resp.json b/doc/v3/api_samples/os-server-groups/server-groups-list-resp.json new file mode 100644 index 000000000000..6b6c28317dab --- /dev/null +++ b/doc/v3/api_samples/os-server-groups/server-groups-list-resp.json @@ -0,0 +1,11 @@ +{ + "server_groups": [ + { + "id": "616fb98f-46ca-475e-917e-2563e5a8cd19", + "name": "test", + "policies": ["anti-affinity"], + "members": [], + "metadata": {} + } + ] +} diff --git a/doc/v3/api_samples/os-server-groups/server-groups-post-req.json b/doc/v3/api_samples/os-server-groups/server-groups-post-req.json new file mode 100644 index 000000000000..83b3b8e5d703 --- /dev/null +++ b/doc/v3/api_samples/os-server-groups/server-groups-post-req.json @@ -0,0 +1,6 @@ +{ + "server_group": { + "name": "test", + "policies": ["anti-affinity"] + } +} diff --git a/doc/v3/api_samples/os-server-groups/server-groups-post-resp.json b/doc/v3/api_samples/os-server-groups/server-groups-post-resp.json new file mode 100644 index 000000000000..f26cc8824e1c --- /dev/null +++ b/doc/v3/api_samples/os-server-groups/server-groups-post-resp.json @@ -0,0 +1,9 @@ +{ + "server_group": { + "id": "5bbcc3c4-1da2-4437-a48a-66f15b1b13f9", + "name": "test", + "policies": ["anti-affinity"], + "members": [], + "metadata": {} + } +} diff --git a/etc/nova/policy.json b/etc/nova/policy.json index b3948c495ebd..01376a51eb2e 100644 --- a/etc/nova/policy.json +++ b/etc/nova/policy.json @@ -228,6 +228,8 @@ "compute_extension:server_usage": "", "compute_extension:v3:os-server-usage": "", "compute_extension:v3:os-server-usage:discoverable": "", + "compute_extension:v3:os-server-groups": "", + "compute_extension:v3:os-server-groups:discoverable": "", "compute_extension:services": "rule:admin_api", "compute_extension:v3:os-services": "rule:admin_api", "compute_extension:v3:os-services:discoverable": "", diff --git a/nova/api/openstack/compute/plugins/v3/server_groups.py b/nova/api/openstack/compute/plugins/v3/server_groups.py new file mode 100644 index 000000000000..657cb341747b --- /dev/null +++ b/nova/api/openstack/compute/plugins/v3/server_groups.py @@ -0,0 +1,198 @@ +# Copyright (c) 2014 Cisco Systems, Inc. +# 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 Server Group API Extension.""" + +import webob +from webob import exc + +from nova.api.openstack import common +from nova.api.openstack import extensions +from nova.api.openstack import wsgi +import nova.exception +from nova.i18n import _ +from nova import objects +from nova import utils + +ALIAS = "os-server-groups" + +# NOTE(russellb) There is one other policy, 'legacy', but we don't allow that +# being set via the API. It's only used when a group gets automatically +# created to support the legacy behavior of the 'group' scheduler hint. +SUPPORTED_POLICIES = ['anti-affinity', 'affinity'] + +authorize = extensions.extension_authorizer('compute', 'v3:' + ALIAS) + + +def _authorize_context(req): + context = req.environ['nova.context'] + authorize(context) + return context + + +class ServerGroupController(wsgi.Controller): + """The Server group API controller for the OpenStack API.""" + + def _format_server_group(self, context, group): + # the id field has its value as the uuid of the server group + # There is no 'uuid' key in server_group seen by clients. + # In addition, clients see policies as a ["policy-name"] list; + # and they see members as a ["server-id"] list. + server_group = {} + server_group['id'] = group.uuid + server_group['name'] = group.name + server_group['policies'] = group.policies or [] + # NOTE(danms): This has been exposed to the user, but never used. + # Since we can't remove it, just make sure it's always empty. + server_group['metadata'] = {} + members = [] + if group.members: + # Display the instances that are not deleted. + filters = {'uuid': group.members, 'deleted': False} + instances = objects.InstanceList.get_by_filters( + context, filters=filters) + members = [instance.uuid for instance in instances] + server_group['members'] = members + return server_group + + def _validate_policies(self, policies): + """Validate the policies. + + Validates that there are no contradicting policies, for example + 'anti-affinity' and 'affinity' in the same group. + Validates that the defined policies are supported. + :param policies: the given policies of the server_group + """ + if ('anti-affinity' in policies and + 'affinity' in policies): + msg = _("Conflicting policies configured!") + raise nova.exception.InvalidInput(reason=msg) + not_supported = [policy for policy in policies + if policy not in SUPPORTED_POLICIES] + if not_supported: + msg = _("Invalid policies: %s") % ', '.join(not_supported) + raise nova.exception.InvalidInput(reason=msg) + + # Note(wingwj): It doesn't make sense to store duplicate policies. + if sorted(set(policies)) != sorted(policies): + msg = _("Duplicate policies configured!") + raise nova.exception.InvalidInput(reason=msg) + + def _validate_input_body(self, body, entity_name): + if not self.is_valid_body(body, entity_name): + msg = _("the body is invalid.") + raise nova.exception.InvalidInput(reason=msg) + + subbody = dict(body[entity_name]) + + expected_fields = ['name', 'policies'] + for field in expected_fields: + value = subbody.pop(field, None) + if not value: + msg = _("'%s' is either missing or empty.") % field + raise nova.exception.InvalidInput(reason=msg) + if field == 'name': + utils.check_string_length(value, field, + min_length=1, max_length=255) + if not common.VALID_NAME_REGEX.search(value): + msg = _("Invalid format for name: '%s'") % value + raise nova.exception.InvalidInput(reason=msg) + elif field == 'policies': + if isinstance(value, list): + [utils.check_string_length(v, field, + min_length=1, max_length=255) for v in value] + self._validate_policies(value) + else: + msg = _("'%s' is not a list") % value + raise nova.exception.InvalidInput(reason=msg) + + if subbody: + msg = _("unsupported fields: %s") % subbody.keys() + raise nova.exception.InvalidInput(reason=msg) + + @extensions.expected_errors(404) + def show(self, req, id): + """Return data about the given server group.""" + context = _authorize_context(req) + try: + sg = objects.InstanceGroup.get_by_uuid(context, id) + except nova.exception.InstanceGroupNotFound as e: + raise webob.exc.HTTPNotFound(explanation=e.format_message()) + return {'server_group': self._format_server_group(context, sg)} + + @extensions.expected_errors(404) + def delete(self, req, id): + """Delete an server group.""" + context = _authorize_context(req) + try: + sg = objects.InstanceGroup.get_by_uuid(context, id) + sg.destroy(context) + except nova.exception.InstanceGroupNotFound as e: + raise webob.exc.HTTPNotFound(explanation=e.format_message()) + return webob.Response(status_int=204) + + @extensions.expected_errors(()) + def index(self, req): + """Returns a list of server groups.""" + context = _authorize_context(req) + project_id = context.project_id + if 'all_projects' in req.GET and context.is_admin: + sgs = objects.InstanceGroupList.get_all(context) + else: + sgs = objects.InstanceGroupList.get_by_project_id( + context, project_id) + limited_list = common.limited(sgs.objects, req) + result = [self._format_server_group(context, group) + for group in limited_list] + return {'server_groups': result} + + @extensions.expected_errors(400) + def create(self, req, body): + """Creates a new server group.""" + context = _authorize_context(req) + + try: + self._validate_input_body(body, 'server_group') + except nova.exception.InvalidInput as e: + raise exc.HTTPBadRequest(explanation=e.format_message()) + + vals = body['server_group'] + sg = objects.InstanceGroup(context) + sg.project_id = context.project_id + sg.user_id = context.user_id + try: + sg.name = vals.get('name') + sg.policies = vals.get('policies') + sg.create() + except ValueError as e: + raise exc.HTTPBadRequest(explanation=e) + + return {'server_group': self._format_server_group(context, sg)} + + +class ServerGroups(extensions.V3APIExtensionBase): + """Server group support.""" + name = "ServerGroups" + alias = ALIAS + version = 1 + + def get_resources(self): + res = extensions.ResourceExtension( + ALIAS, controller=ServerGroupController(), + member_actions={"action": "POST", }) + return [res] + + def get_controller_extensions(self): + return [] diff --git a/nova/tests/api/openstack/compute/contrib/test_server_groups.py b/nova/tests/api/openstack/compute/contrib/test_server_groups.py index 54b1241d7170..db59ee38c3c7 100644 --- a/nova/tests/api/openstack/compute/contrib/test_server_groups.py +++ b/nova/tests/api/openstack/compute/contrib/test_server_groups.py @@ -17,6 +17,7 @@ from lxml import etree import webob from nova.api.openstack.compute.contrib import server_groups +from nova.api.openstack.compute.plugins.v3 import server_groups as sg_v3 from nova.api.openstack import wsgi from nova import context import nova.db @@ -78,20 +79,29 @@ def server_group_db(sg): return AttrDict(attrs) -class ServerGroupTest(test.TestCase): +class ServerGroupTestV21(test.TestCase): + + sg_controller_cls = sg_v3.ServerGroupController + def setUp(self): - super(ServerGroupTest, self).setUp() - self.controller = server_groups.ServerGroupController() - self.app = fakes.wsgi_app(init_only=('os-server-groups',)) + super(ServerGroupTestV21, self).setUp() + self.controller = self.sg_controller_cls() + self.app = self._get_app() + + def _get_app(self): + return fakes.wsgi_app_v3(init_only=('os-server-groups',)) + + def _get_url(self): + return '/v3' def test_create_server_group_with_no_policies(self): - req = fakes.HTTPRequest.blank('/v2/fake/os-server-groups') + req = fakes.HTTPRequest.blank(self._get_url() + '/os-server-groups') sgroup = server_group_template() self.assertRaises(webob.exc.HTTPBadRequest, self.controller.create, req, {'server_group': sgroup}) def test_create_server_group_normal(self): - req = fakes.HTTPRequest.blank('/v2/fake/os-server-groups') + req = fakes.HTTPRequest.blank(self._get_url() + '/os-server-groups') sgroup = server_group_template() policies = ['anti-affinity'] sgroup['policies'] = policies @@ -123,7 +133,7 @@ class ServerGroupTest(test.TestCase): def test_display_members(self): ctx = context.RequestContext('fake_user', 'fake') (ig_uuid, instances, members) = self._create_groups_and_instances(ctx) - req = fakes.HTTPRequest.blank('/v2/fake/os-server-groups') + req = fakes.HTTPRequest.blank(self._get_url() + '/os-server-groups') res_dict = self.controller.show(req, ig_uuid) result_members = res_dict['server_group']['members'] self.assertEqual(2, len(result_members)) @@ -133,7 +143,7 @@ class ServerGroupTest(test.TestCase): def test_display_active_members_only(self): ctx = context.RequestContext('fake_user', 'fake') (ig_uuid, instances, members) = self._create_groups_and_instances(ctx) - req = fakes.HTTPRequest.blank('/v2/fake/os-server-groups') + req = fakes.HTTPRequest.blank(self._get_url() + '/os-server-groups') # delete an instance instances[1].destroy(ctx) @@ -150,66 +160,66 @@ class ServerGroupTest(test.TestCase): def test_create_server_group_with_illegal_name(self): # blank name sgroup = server_group_template(name='', policies=['test_policy']) - req = fakes.HTTPRequest.blank('/v2/fake/os-server-groups') + req = fakes.HTTPRequest.blank(self._get_url() + '/os-server-groups') self.assertRaises(webob.exc.HTTPBadRequest, self.controller.create, req, {'server_group': sgroup}) # name with length 256 sgroup = server_group_template(name='1234567890' * 26, policies=['test_policy']) - req = fakes.HTTPRequest.blank('/v2/fake/os-server-groups') + req = fakes.HTTPRequest.blank(self._get_url() + '/os-server-groups') self.assertRaises(webob.exc.HTTPBadRequest, self.controller.create, req, {'server_group': sgroup}) # non-string name sgroup = server_group_template(name=12, policies=['test_policy']) - req = fakes.HTTPRequest.blank('/v2/fake/os-server-groups') + req = fakes.HTTPRequest.blank(self._get_url() + '/os-server-groups') self.assertRaises(webob.exc.HTTPBadRequest, self.controller.create, req, {'server_group': sgroup}) # name with leading spaces sgroup = server_group_template(name=' leading spaces', policies=['test_policy']) - req = fakes.HTTPRequest.blank('/v2/fake/os-server-groups') + req = fakes.HTTPRequest.blank(self._get_url() + '/os-server-groups') self.assertRaises(webob.exc.HTTPBadRequest, self.controller.create, req, {'server_group': sgroup}) # name with trailing spaces sgroup = server_group_template(name='trailing space ', policies=['test_policy']) - req = fakes.HTTPRequest.blank('/v2/fake/os-server-groups') + req = fakes.HTTPRequest.blank(self._get_url() + '/os-server-groups') self.assertRaises(webob.exc.HTTPBadRequest, self.controller.create, req, {'server_group': sgroup}) # name with all spaces sgroup = server_group_template(name=' ', policies=['test_policy']) - req = fakes.HTTPRequest.blank('/v2/fake/os-server-groups') + req = fakes.HTTPRequest.blank(self._get_url() + '/os-server-groups') self.assertRaises(webob.exc.HTTPBadRequest, self.controller.create, req, {'server_group': sgroup}) def test_create_server_group_with_illegal_policies(self): # blank policy sgroup = server_group_template(name='fake-name', policies='') - req = fakes.HTTPRequest.blank('/v2/fake/os-server-groups') + req = fakes.HTTPRequest.blank(self._get_url() + '/os-server-groups') self.assertRaises(webob.exc.HTTPBadRequest, self.controller.create, req, {'server_group': sgroup}) # policy as integer sgroup = server_group_template(name='fake-name', policies=7) - req = fakes.HTTPRequest.blank('/v2/fake/os-server-groups') + req = fakes.HTTPRequest.blank(self._get_url() + '/os-server-groups') self.assertRaises(webob.exc.HTTPBadRequest, self.controller.create, req, {'server_group': sgroup}) # policy as string sgroup = server_group_template(name='fake-name', policies='invalid') - req = fakes.HTTPRequest.blank('/v2/fake/os-server-groups') + req = fakes.HTTPRequest.blank(self._get_url() + '/os-server-groups') self.assertRaises(webob.exc.HTTPBadRequest, self.controller.create, req, {'server_group': sgroup}) # policy as None sgroup = server_group_template(name='fake-name', policies=None) - req = fakes.HTTPRequest.blank('/v2/fake/os-server-groups') + req = fakes.HTTPRequest.blank(self._get_url() + '/os-server-groups') self.assertRaises(webob.exc.HTTPBadRequest, self.controller.create, req, {'server_group': sgroup}) @@ -217,7 +227,7 @@ class ServerGroupTest(test.TestCase): sgroup = server_group_template() policies = ['anti-affinity', 'affinity'] sgroup['policies'] = policies - req = fakes.HTTPRequest.blank('/v2/fake/os-server-groups') + req = fakes.HTTPRequest.blank(self._get_url() + '/os-server-groups') self.assertRaises(webob.exc.HTTPBadRequest, self.controller.create, req, {'server_group': sgroup}) @@ -225,7 +235,7 @@ class ServerGroupTest(test.TestCase): sgroup = server_group_template() policies = ['affinity', 'affinity'] sgroup['policies'] = policies - req = fakes.HTTPRequest.blank('/v2/fake/os-server-groups') + req = fakes.HTTPRequest.blank(self._get_url() + '/os-server-groups') self.assertRaises(webob.exc.HTTPBadRequest, self.controller.create, req, {'server_group': sgroup}) @@ -233,18 +243,18 @@ class ServerGroupTest(test.TestCase): sgroup = server_group_template() policies = ['storage-affinity', 'anti-affinity', 'rack-affinity'] sgroup['policies'] = policies - req = fakes.HTTPRequest.blank('/v2/fake/os-server-groups') + req = fakes.HTTPRequest.blank(self._get_url() + '/os-server-groups') self.assertRaises(webob.exc.HTTPBadRequest, self.controller.create, req, {'server_group': sgroup}) def test_create_server_group_with_no_body(self): - req = fakes.HTTPRequest.blank('/v2/fake/os-server-groups') + req = fakes.HTTPRequest.blank(self._get_url() + '/os-server-groups') self.assertRaises(webob.exc.HTTPBadRequest, self.controller.create, req, None) def test_create_server_group_with_no_server_group(self): body = {'no-instanceGroup': None} - req = fakes.HTTPRequest.blank('/v2/fake/os-server-groups') + req = fakes.HTTPRequest.blank(self._get_url() + '/os-server-groups') self.assertRaises(webob.exc.HTTPBadRequest, self.controller.create, req, body) @@ -273,7 +283,7 @@ class ServerGroupTest(test.TestCase): self.stubs.Set(nova.db, 'instance_group_get_all_by_project_id', return_server_groups) - req = fakes.HTTPRequest.blank('/v2/fake/os-server-groups') + req = fakes.HTTPRequest.blank(self._get_url() + '/os-server-groups') res_dict = self.controller.index(req) self.assertEqual(res_dict, expected) @@ -312,7 +322,7 @@ class ServerGroupTest(test.TestCase): self.stubs.Set(nova.db, 'instance_group_get_all_by_project_id', return_tenant_server_groups) - path = '/v2/fake/os-server-groups?all_projects=True' + path = self._get_url() + '/os-server-groups?all_projects=True' req = fakes.HTTPRequest.blank(path, use_admin_context=True) res_dict = self.controller.index(req) @@ -338,17 +348,29 @@ class ServerGroupTest(test.TestCase): self.stubs.Set(nova.db, 'instance_group_get', return_server_group) - req = fakes.HTTPRequest.blank('/v2/fake/os-server-groups/123') + req = fakes.HTTPRequest.blank(self._get_url() + + '/os-server-groups/123') resp = self.controller.delete(req, '123') self.assertTrue(self.called) self.assertEqual(resp.status_int, 204) def test_delete_non_existing_server_group(self): - req = fakes.HTTPRequest.blank('/v2/fake/os-server-groups/invalid') + req = fakes.HTTPRequest.blank(self._get_url() + + '/os-server-groups/invalid') self.assertRaises(webob.exc.HTTPNotFound, self.controller.delete, req, 'invalid') +class ServerGroupTestV2(ServerGroupTestV21): + sg_controller_cls = server_groups.ServerGroupController + + def _get_app(self): + return fakes.wsgi_app(init_only=('os-server-groups',)) + + def _get_url(self): + return '/v2/fake' + + class TestServerGroupXMLDeserializer(test.TestCase): def setUp(self): diff --git a/nova/tests/fake_policy.py b/nova/tests/fake_policy.py index f4d74218baab..71e3b46d9c04 100644 --- a/nova/tests/fake_policy.py +++ b/nova/tests/fake_policy.py @@ -273,6 +273,7 @@ policy_data = """ "compute_extension:v3:os-server-password": "", "compute_extension:server_usage": "", "compute_extension:v3:os-server-usage": "", + "compute_extension:v3:os-server-groups": "", "compute_extension:services": "", "compute_extension:v3:os-services": "", "compute_extension:shelve": "", diff --git a/nova/tests/integrated/v3/api_samples/os-server-groups/server-groups-get-resp.json.tpl b/nova/tests/integrated/v3/api_samples/os-server-groups/server-groups-get-resp.json.tpl new file mode 100644 index 000000000000..ba72643b6d85 --- /dev/null +++ b/nova/tests/integrated/v3/api_samples/os-server-groups/server-groups-get-resp.json.tpl @@ -0,0 +1,9 @@ +{ + "server_group": { + "id": "%(id)s", + "name": "%(name)s", + "policies": ["anti-affinity"], + "members": [], + "metadata": {} + } +} diff --git a/nova/tests/integrated/v3/api_samples/os-server-groups/server-groups-list-resp.json.tpl b/nova/tests/integrated/v3/api_samples/os-server-groups/server-groups-list-resp.json.tpl new file mode 100644 index 000000000000..f01d451dd248 --- /dev/null +++ b/nova/tests/integrated/v3/api_samples/os-server-groups/server-groups-list-resp.json.tpl @@ -0,0 +1,11 @@ +{ + "server_groups": [ + { + "id": "%(id)s", + "name": "test", + "policies": ["anti-affinity"], + "members": [], + "metadata": {} + } + ] +} diff --git a/nova/tests/integrated/v3/api_samples/os-server-groups/server-groups-post-req.json.tpl b/nova/tests/integrated/v3/api_samples/os-server-groups/server-groups-post-req.json.tpl new file mode 100644 index 000000000000..1cc232832044 --- /dev/null +++ b/nova/tests/integrated/v3/api_samples/os-server-groups/server-groups-post-req.json.tpl @@ -0,0 +1,6 @@ +{ + "server_group": { + "name": "%(name)s", + "policies": ["anti-affinity"] + } +} diff --git a/nova/tests/integrated/v3/api_samples/os-server-groups/server-groups-post-resp.json.tpl b/nova/tests/integrated/v3/api_samples/os-server-groups/server-groups-post-resp.json.tpl new file mode 100644 index 000000000000..ee9c37e82c11 --- /dev/null +++ b/nova/tests/integrated/v3/api_samples/os-server-groups/server-groups-post-resp.json.tpl @@ -0,0 +1,10 @@ +{ + "server_group": { + "id": "%(id)s", + "name": "%(name)s", + "policies": ["anti-affinity"], + "members": [], + "metadata": {} + } +} + diff --git a/nova/tests/integrated/v3/test_server_groups.py b/nova/tests/integrated/v3/test_server_groups.py new file mode 100644 index 000000000000..ff00fca376d1 --- /dev/null +++ b/nova/tests/integrated/v3/test_server_groups.py @@ -0,0 +1,66 @@ +# Copyright 2012 Nebula, Inc. +# Copyright 2014 IBM Corp. +# +# 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 nova.tests.integrated.v3 import test_servers + + +class ServerGroupsSampleJsonTest(test_servers.ServersSampleBase): + extension_name = "os-server-groups" + + def _get_create_subs(self): + return {'name': 'test'} + + def _post_server_group(self): + """Verify the response status and returns the UUID of the + newly created server group. + """ + subs = self._get_create_subs() + response = self._do_post('os-server-groups', + 'server-groups-post-req', subs) + subs = self._get_regexes() + subs['name'] = 'test' + return self._verify_response('server-groups-post-resp', + subs, response, 200) + + def _create_server_group(self): + subs = self._get_create_subs() + return self._do_post('os-server-groups', + 'server-groups-post-req', subs) + + def test_server_groups_post(self): + return self._post_server_group() + + def test_server_groups_list(self): + subs = self._get_create_subs() + uuid = self._post_server_group() + response = self._do_get('os-server-groups') + subs.update(self._get_regexes()) + subs['id'] = uuid + self._verify_response('server-groups-list-resp', + subs, response, 200) + + def test_server_groups_get(self): + # Get api sample of server groups get request. + subs = {'name': 'test'} + uuid = self._post_server_group() + subs['id'] = uuid + response = self._do_get('os-server-groups/%s' % uuid) + + self._verify_response('server-groups-get-resp', subs, response, 200) + + def test_server_groups_delete(self): + uuid = self._post_server_group() + response = self._do_delete('os-server-groups/%s' % uuid) + self.assertEqual(response.status, 204) diff --git a/setup.cfg b/setup.cfg index 46b34e46c1c0..8dbda1fcfae6 100644 --- a/setup.cfg +++ b/setup.cfg @@ -106,6 +106,7 @@ nova.api.v3.extensions = server_metadata = nova.api.openstack.compute.plugins.v3.server_metadata:ServerMetadata server_password = nova.api.openstack.compute.plugins.v3.server_password:ServerPassword server_usage = nova.api.openstack.compute.plugins.v3.server_usage:ServerUsage + server_groups = nova.api.openstack.compute.plugins.v3.server_groups:ServerGroups servers = nova.api.openstack.compute.plugins.v3.servers:Servers services = nova.api.openstack.compute.plugins.v3.services:Services shelve = nova.api.openstack.compute.plugins.v3.shelve:Shelve