diff --git a/etc/nova/policy.json b/etc/nova/policy.json index dc35f0c20444..2ab02dbd4498 100644 --- a/etc/nova/policy.json +++ b/etc/nova/policy.json @@ -444,6 +444,12 @@ "os_compute_api:os-server-usage:discoverable": "@", "os_compute_api:os-server-groups": "rule:admin_or_owner", "os_compute_api:os-server-groups:discoverable": "@", + "os_compute_api:os-server-tags:index": "@", + "os_compute_api:os-server-tags:show": "@", + "os_compute_api:os-server-tags:update": "@", + "os_compute_api:os-server-tags:update_all": "@", + "os_compute_api:os-server-tags:delete": "@", + "os_compute_api:os-server-tags:delete_all": "@", "os_compute_api:os-services": "rule:admin_api", "os_compute_api:os-services:discoverable": "@", "os_compute_api:server-metadata:discoverable": "@", diff --git a/nova/api/openstack/compute/schemas/server_tags.py b/nova/api/openstack/compute/schemas/server_tags.py new file mode 100644 index 000000000000..a236a30547a8 --- /dev/null +++ b/nova/api/openstack/compute/schemas/server_tags.py @@ -0,0 +1,43 @@ +# 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. + +tag = { + "type": "string", + "pattern": "^[^,/]*$" +} + +update_all = { + "definitions": { + "tag": { + "type": "string" + } + }, + "title": "Server tags", + "type": "object", + "properties": { + "tags": { + "type": "array", + "items": { + "$ref": "#/definitions/tag" + } + } + }, + 'required': ['tags'], + 'additionalProperties': False +} + +update = { + "title": "Server tag", + "type": "null", + 'required': [], + 'additionalProperties': False +} diff --git a/nova/api/openstack/compute/server_tags.py b/nova/api/openstack/compute/server_tags.py new file mode 100644 index 000000000000..93d9bc6b7ea0 --- /dev/null +++ b/nova/api/openstack/compute/server_tags.py @@ -0,0 +1,196 @@ +# 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 jsonschema + +from webob import exc + +from nova.api.openstack.compute.schemas import server_tags as schema +from nova.api.openstack.compute.views import server_tags +from nova.api.openstack import extensions +from nova.api.openstack import wsgi +from nova.api import validation +from nova import exception +from nova.i18n import _ +from nova import objects + + +ALIAS = "os-server-tags" +authorize = extensions.os_compute_authorizer(ALIAS) + + +def _get_tags_names(tags): + return [t.tag for t in tags] + + +class ServerTagsController(wsgi.Controller): + _view_builder_class = server_tags.ViewBuilder + + @wsgi.response(204) + @extensions.expected_errors(404) + def show(self, req, server_id, id): + context = req.environ["nova.context"] + authorize(context, action='show') + + try: + exists = objects.Tag.exists(context, server_id, id) + except exception.InstanceNotFound as e: + raise exc.HTTPNotFound(explanation=e.format_message()) + + if not exists: + msg = (_("Server %(server_id)s has no tag '%(tag)s'") + % {'server_id': server_id, 'tag': id}) + raise exc.HTTPNotFound(explanation=msg) + + @extensions.expected_errors(404) + def index(self, req, server_id): + context = req.environ["nova.context"] + authorize(context, action='index') + + try: + tags = objects.TagList.get_by_resource_id(context, server_id) + except exception.InstanceNotFound as e: + raise exc.HTTPNotFound(explanation=e.format_message()) + + return {'tags': _get_tags_names(tags)} + + @extensions.expected_errors((400, 404)) + @validation.schema(schema.update) + def update(self, req, server_id, id, body): + context = req.environ["nova.context"] + authorize(context, action='update') + + try: + jsonschema.validate(id, schema.tag) + except jsonschema.ValidationError as e: + msg = (_("Tag '%(tag)s' is invalid. It must be a string without " + "characters '/' and ','. Validation error message: " + "%(err)s") % {'tag': id, 'err': e.message}) + raise exc.HTTPBadRequest(explanation=msg) + + try: + tags = objects.TagList.get_by_resource_id(context, server_id) + except exception.InstanceNotFound as e: + raise exc.HTTPNotFound(explanation=e.format_message()) + + if len(tags) >= objects.instance.MAX_TAG_COUNT: + msg = (_("The number of tags exceeded the per-server limit %d") + % objects.instance.MAX_TAG_COUNT) + raise exc.HTTPBadRequest(explanation=msg) + + if len(id) > objects.tag.MAX_TAG_LENGTH: + msg = (_("Tag '%(tag)s' is too long. Maximum length of a tag " + "is %(length)d") % {'tag': id, + 'length': objects.tag.MAX_TAG_LENGTH}) + raise exc.HTTPBadRequest(explanation=msg) + + if id in _get_tags_names(tags): + # NOTE(snikitin): server already has specified tag + return exc.HTTPNoContent() + + tag = objects.Tag(context=context, resource_id=server_id, tag=id) + + try: + tag.create() + except exception.InstanceNotFound as e: + raise exc.HTTPNotFound(explanation=e.format_message()) + + response = exc.HTTPCreated() + response.headers['Location'] = self._view_builder.get_location( + req, server_id, id) + return response + + @extensions.expected_errors((400, 404)) + @validation.schema(schema.update_all) + def update_all(self, req, server_id, body): + context = req.environ["nova.context"] + authorize(context, action='update_all') + + invalid_tags = [] + for tag in body['tags']: + try: + jsonschema.validate(tag, schema.tag) + except jsonschema.ValidationError: + invalid_tags.append(tag) + if invalid_tags: + msg = (_("Tags '%s' are invalid. Each tag must be a string " + "without characters '/' and ','.") % invalid_tags) + raise exc.HTTPBadRequest(explanation=msg) + + tag_count = len(body['tags']) + if tag_count > objects.instance.MAX_TAG_COUNT: + msg = (_("The number of tags exceeded the per-server limit " + "%(max)d. The number of tags in request is %(count)d.") + % {'max': objects.instance.MAX_TAG_COUNT, + 'count': tag_count}) + raise exc.HTTPBadRequest(explanation=msg) + + long_tags = [ + t for t in body['tags'] if len(t) > objects.tag.MAX_TAG_LENGTH] + if long_tags: + msg = (_("Tags %(tags)s are too long. Maximum length of a tag " + "is %(length)d") % {'tags': long_tags, + 'length': objects.tag.MAX_TAG_LENGTH}) + raise exc.HTTPBadRequest(explanation=msg) + + try: + tags = objects.TagList.create(context, server_id, body['tags']) + except exception.InstanceNotFound as e: + raise exc.HTTPNotFound(explanation=e.format_message()) + + return {'tags': _get_tags_names(tags)} + + @wsgi.response(204) + @extensions.expected_errors(404) + def delete(self, req, server_id, id): + context = req.environ["nova.context"] + authorize(context, action='delete') + + try: + objects.Tag.destroy(context, server_id, id) + except exception.InstanceTagNotFound as e: + raise exc.HTTPNotFound(explanation=e.format_message()) + except exception.InstanceNotFound as e: + raise exc.HTTPNotFound(explanation=e.format_message()) + + @wsgi.response(204) + @extensions.expected_errors(404) + def delete_all(self, req, server_id): + context = req.environ["nova.context"] + authorize(context, action='delete_all') + + try: + objects.TagList.destroy(context, server_id) + except exception.InstanceNotFound as e: + raise exc.HTTPNotFound(explanation=e.format_message()) + + +class ServerTags(extensions.V21APIExtensionBase): + """Server tags support.""" + + name = "ServerTags" + alias = ALIAS + version = 1 + + def get_controller_extensions(self): + return [] + + def get_resources(self): + res = extensions.ResourceExtension('tags', + ServerTagsController(), + parent=dict( + member_name='server', + collection_name='servers'), + collection_actions={ + 'delete_all': 'DELETE', + 'update_all': 'PUT'}) + return [res] diff --git a/nova/api/openstack/compute/views/server_tags.py b/nova/api/openstack/compute/views/server_tags.py new file mode 100644 index 000000000000..27a53f9af235 --- /dev/null +++ b/nova/api/openstack/compute/views/server_tags.py @@ -0,0 +1,30 @@ +# Copyright 2016 Mirantis 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. + +from nova.api.openstack import common +from nova.api.openstack.compute.views import servers + + +class ViewBuilder(common.ViewBuilder): + _collection_name = "tags" + + def __init__(self): + super(ViewBuilder, self).__init__() + self._server_builder = servers.ViewBuilder() + + def get_location(self, request, server_id, tag_name): + server_location = self._server_builder._get_href_link( + request, server_id, "servers") + return "%s/%s/%s" % (server_location, self._collection_name, tag_name) diff --git a/nova/objects/instance.py b/nova/objects/instance.py index e759fb582b95..6146c41538b1 100644 --- a/nova/objects/instance.py +++ b/nova/objects/instance.py @@ -57,6 +57,9 @@ INSTANCE_OPTIONAL_ATTRS = (_INSTANCE_OPTIONAL_JOINED_FIELDS + INSTANCE_DEFAULT_FIELDS = ['metadata', 'system_metadata', 'info_cache', 'security_groups'] +# Maximum count of tags to one instance +MAX_TAG_COUNT = 50 + def _expected_cols(expected_attrs): """Return expected_attrs that are columns needing joining. diff --git a/nova/objects/tag.py b/nova/objects/tag.py index 11483acd96a7..081999c17ba4 100644 --- a/nova/objects/tag.py +++ b/nova/objects/tag.py @@ -15,6 +15,8 @@ from nova import objects from nova.objects import base from nova.objects import fields +MAX_TAG_LENGTH = 60 + @base.NovaObjectRegistry.register class Tag(base.NovaObject): diff --git a/nova/tests/unit/api/openstack/compute/test_server_tags.py b/nova/tests/unit/api/openstack/compute/test_server_tags.py new file mode 100644 index 000000000000..48e42dccf960 --- /dev/null +++ b/nova/tests/unit/api/openstack/compute/test_server_tags.py @@ -0,0 +1,256 @@ +# 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 mock +from webob import exc + +from nova.api.openstack.compute import extension_info +from nova.api.openstack.compute import server_tags +from nova.api.openstack.compute import servers +from nova.db.sqlalchemy import models +from nova import exception +from nova.objects import instance +from nova.objects import tag as tag_obj +from nova import test +from nova.tests.unit.api.openstack import fakes + +UUID = 'b48316c5-71e8-45e4-9884-6c78055b9b13' +TAG1 = 'tag1' +TAG2 = 'tag2' +TAG3 = 'tag3' +TAGS = [TAG1, TAG2, TAG3] +NON_EXISTING_UUID = '123' + + +class ServerTagsTest(test.TestCase): + def setUp(self): + super(ServerTagsTest, self).setUp() + self.controller = server_tags.ServerTagsController() + + def _get_tag(self, tag_name): + tag = models.Tag() + tag.tag = tag_name + tag.resource_id = UUID + return tag + + def _get_request(self, url, method): + request = fakes.HTTPRequest.blank(url) + request.method = method + return request + + @mock.patch('nova.db.instance_tag_exists') + def test_show(self, mock_exists): + mock_exists.return_value = True + req = self._get_request( + '/v2/fake/servers/%s/tags/%s' % (UUID, TAG1), 'GET') + context = req.environ["nova.context"] + + self.controller.show(req, UUID, TAG1) + mock_exists.assert_called_once_with(context, UUID, TAG1) + + @mock.patch('nova.db.instance_tag_get_by_instance_uuid') + def test_index(self, mock_db_get_inst_tags): + fake_tags = [self._get_tag(tag) for tag in TAGS] + mock_db_get_inst_tags.return_value = fake_tags + + req = self._get_request('/v2/fake/servers/%s/tags' % UUID, 'GET') + context = req.environ["nova.context"] + + res = self.controller.index(req, UUID) + self.assertEqual(TAGS, res.get('tags')) + mock_db_get_inst_tags.assert_called_once_with(context, UUID) + + @mock.patch('nova.db.instance_tag_set') + def test_update_all(self, mock_db_set_inst_tags): + fake_tags = [self._get_tag(tag) for tag in TAGS] + mock_db_set_inst_tags.return_value = fake_tags + req = self._get_request( + '/v2/fake/servers/%s/tags' % UUID, 'PUT') + context = req.environ["nova.context"] + res = self.controller.update_all(req, UUID, body={'tags': TAGS}) + + self.assertEqual(TAGS, res['tags']) + mock_db_set_inst_tags.assert_called_once_with(context, UUID, TAGS) + + def test_update_all_too_many_tags(self): + fake_tags = {'tags': [str(i) for i in xrange( + instance.MAX_TAG_COUNT + 1)]} + + req = self._get_request( + '/v2/fake/servers/%s/tags' % UUID, 'PUT') + self.assertRaises(exc.HTTPBadRequest, self.controller.update_all, + req, UUID, body=fake_tags) + + def test_update_all_forbidden_characters(self): + req = self._get_request('/v2/fake/servers/%s/tags' % UUID, 'PUT') + for tag in ['tag,1', 'tag/1']: + self.assertRaises(exc.HTTPBadRequest, + self.controller.update_all, + req, UUID, body={'tags': [tag, 'tag2']}) + + def test_update_all_invalid_tag_type(self): + req = self._get_request('/v2/fake/servers/%s/tags' % UUID, 'PUT') + self.assertRaises(exception.ValidationError, + self.controller.update_all, + req, UUID, body={'tags': [1]}) + + def test_update_all_too_long_tag(self): + req = self._get_request('/v2/fake/servers/%s/tags' % UUID, 'PUT') + tag = "a" * (tag_obj.MAX_TAG_LENGTH + 1) + self.assertRaises(exc.HTTPBadRequest, self.controller.update_all, + req, UUID, body={'tags': [tag]}) + + def test_update_all_invalid_tag_list_type(self): + req = self._get_request('/v2/ake/servers/%s/tags' % UUID, 'PUT') + self.assertRaises(exception.ValidationError, + self.controller.update_all, + req, UUID, body={'tags': {'tag': 'tag'}}) + + @mock.patch('nova.db.instance_tag_exists') + def test_show_non_existing_tag(self, mock_exists): + mock_exists.return_value = False + req = self._get_request( + '/v2/fake/servers/%s/tags/%s' % (UUID, TAG1), 'GET') + self.assertRaises(exc.HTTPNotFound, self.controller.show, + req, UUID, TAG1) + + @mock.patch('nova.db.instance_tag_add') + @mock.patch('nova.db.instance_tag_get_by_instance_uuid') + def test_update(self, mock_db_get_inst_tags, mock_db_add_inst_tags): + mock_db_get_inst_tags.return_value = [self._get_tag(TAG1)] + mock_db_add_inst_tags.return_value = self._get_tag(TAG2) + + url = '/v2/fake/servers/%s/tags/%s' % (UUID, TAG2) + location = 'http://localhost' + url + req = self._get_request(url, 'PUT') + context = req.environ["nova.context"] + res = self.controller.update(req, UUID, TAG2, body=None) + + self.assertEqual(201, res.status_int) + self.assertEqual(location, res.headers['Location']) + mock_db_add_inst_tags.assert_called_once_with(context, UUID, TAG2) + mock_db_get_inst_tags.assert_called_once_with(context, UUID) + + @mock.patch('nova.db.instance_tag_get_by_instance_uuid') + def test_update_existing_tag(self, mock_db_get_inst_tags): + mock_db_get_inst_tags.return_value = [self._get_tag(TAG1)] + + req = self._get_request( + '/v2/fake/servers/%s/tags/%s' % (UUID, TAG1), 'PUT') + context = req.environ["nova.context"] + res = self.controller.update(req, UUID, TAG1, body=None) + + self.assertEqual(204, res.status_int) + mock_db_get_inst_tags.assert_called_once_with(context, UUID) + + @mock.patch('nova.db.instance_tag_get_by_instance_uuid') + def test_update_tag_limit_exceed(self, mock_db_get_inst_tags): + fake_tags = [self._get_tag(str(i)) + for i in xrange(instance.MAX_TAG_COUNT)] + mock_db_get_inst_tags.return_value = fake_tags + + req = self._get_request( + '/v2/fake/servers/%s/tags/%s' % (UUID, TAG2), 'PUT') + self.assertRaises(exc.HTTPBadRequest, self.controller.update, + req, UUID, TAG2, body=None) + + @mock.patch('nova.db.instance_tag_get_by_instance_uuid') + def test_update_too_long_tag(self, mock_db_get_inst_tags): + mock_db_get_inst_tags.return_value = [] + + tag = "a" * (tag_obj.MAX_TAG_LENGTH + 1) + req = self._get_request( + '/v2/fake/servers/%s/tags/%s' % (UUID, tag), 'PUT') + self.assertRaises(exc.HTTPBadRequest, self.controller.update, + req, UUID, tag, body=None) + + @mock.patch('nova.db.instance_tag_get_by_instance_uuid') + def test_update_forbidden_characters(self, mock_db_get_inst_tags): + mock_db_get_inst_tags.return_value = [] + for tag in ['tag,1', 'tag/1']: + req = self._get_request( + '/v2/fake/servers/%s/tags/%s' % (UUID, tag), 'PUT') + self.assertRaises(exc.HTTPBadRequest, self.controller.update, + req, UUID, tag, body=None) + + @mock.patch('nova.db.instance_tag_delete') + def test_delete(self, mock_db_delete_inst_tags): + req = self._get_request( + '/v2/fake/servers/%s/tags/%s' % (UUID, TAG2), 'DELETE') + context = req.environ["nova.context"] + self.controller.delete(req, UUID, TAG2) + mock_db_delete_inst_tags.assert_called_once_with(context, UUID, TAG2) + + @mock.patch('nova.db.instance_tag_delete') + def test_delete_non_existing_tag(self, mock_db_delete_inst_tags): + def fake_db_delete_tag(context, instance_uuid, tag): + self.assertEqual(UUID, instance_uuid) + self.assertEqual(TAG1, tag) + raise exception.InstanceTagNotFound(instance_id=instance_uuid, + tag=tag) + mock_db_delete_inst_tags.side_effect = fake_db_delete_tag + req = self._get_request( + '/v2/fake/servers/%s/tags/%s' % (UUID, TAG1), 'DELETE') + self.assertRaises(exc.HTTPNotFound, self.controller.delete, + req, UUID, TAG1) + + @mock.patch('nova.db.instance_tag_delete_all') + def test_delete_all(self, mock_db_delete_inst_tags): + req = self._get_request('/v2/fake/servers/%s/tags' % UUID, 'DELETE') + context = req.environ["nova.context"] + self.controller.delete_all(req, UUID) + mock_db_delete_inst_tags.assert_called_once_with(context, UUID) + + def test_show_non_existing_instance(self): + req = self._get_request( + '/v2/fake/servers/%s/tags/%s' % (NON_EXISTING_UUID, TAG1), 'GET') + self.assertRaises(exc.HTTPNotFound, self.controller.show, req, + NON_EXISTING_UUID, TAG1) + + def test_show_with_details_information_non_existing_instance(self): + req = self._get_request( + '/v2/fake/servers/%s' % NON_EXISTING_UUID, 'GET') + ext_info = extension_info.LoadedExtensionInfo() + servers_controller = servers.ServersController(extension_info=ext_info) + self.assertRaises(exc.HTTPNotFound, servers_controller.show, req, + NON_EXISTING_UUID) + + def test_index_non_existing_instance(self): + req = self._get_request( + 'v2/fake/servers/%s/tags' % NON_EXISTING_UUID, 'GET') + self.assertRaises(exc.HTTPNotFound, self.controller.index, req, + NON_EXISTING_UUID) + + def test_update_non_existing_instance(self): + req = self._get_request( + '/v2/fake/servers/%s/tags/%s' % (NON_EXISTING_UUID, TAG1), 'PUT') + self.assertRaises(exc.HTTPNotFound, self.controller.update, req, + NON_EXISTING_UUID, TAG1, body=None) + + def test_update_all_non_existing_instance(self): + req = self._get_request( + '/v2/fake/servers/%s/tags' % NON_EXISTING_UUID, 'PUT') + self.assertRaises(exc.HTTPNotFound, self.controller.update_all, req, + NON_EXISTING_UUID, body={'tags': TAGS}) + + def test_delete_non_existing_instance(self): + req = self._get_request( + '/v2/fake/servers/%s/tags/%s' % (NON_EXISTING_UUID, TAG1), + 'DELETE') + self.assertRaises(exc.HTTPNotFound, self.controller.delete, req, + NON_EXISTING_UUID, TAG1) + + def test_delete_all_non_existing_instance(self): + req = self._get_request( + '/v2/fake/servers/%s/tags' % NON_EXISTING_UUID, 'DELETE') + self.assertRaises(exc.HTTPNotFound, self.controller.delete_all, + req, NON_EXISTING_UUID) diff --git a/nova/tests/unit/fake_policy.py b/nova/tests/unit/fake_policy.py index 540a3b164bdb..ccbbd448e1b0 100644 --- a/nova/tests/unit/fake_policy.py +++ b/nova/tests/unit/fake_policy.py @@ -321,6 +321,12 @@ policy_data = """ "compute_extension:server_groups": "", "compute_extension:server_password": "", "os_compute_api:os-server-password": "", + "os_compute_api:os-server-tags:index": "", + "os_compute_api:os-server-tags:show": "", + "os_compute_api:os-server-tags:update": "", + "os_compute_api:os-server-tags:update_all": "", + "os_compute_api:os-server-tags:delete": "", + "os_compute_api:os-server-tags:delete_all": "", "compute_extension:server_usage": "", "os_compute_api:os-server-usage": "", "os_compute_api:os-server-groups": "", diff --git a/nova/tests/unit/test_policy.py b/nova/tests/unit/test_policy.py index 998106a61f00..9baa116d3ae9 100644 --- a/nova/tests/unit/test_policy.py +++ b/nova/tests/unit/test_policy.py @@ -692,6 +692,12 @@ class RealRolePolicyTestCase(test.NoDBTestCase): "os_compute_api:os-server-password:discoverable", "os_compute_api:os-server-usage:discoverable", "os_compute_api:os-server-groups:discoverable", +"os_compute_api:os-server-tags:delete", +"os_compute_api:os-server-tags:delete_all", +"os_compute_api:os-server-tags:index", +"os_compute_api:os-server-tags:show", +"os_compute_api:os-server-tags:update", +"os_compute_api:os-server-tags:update_all", "os_compute_api:os-services:discoverable", "os_compute_api:server-metadata:discoverable", "os_compute_api:servers:discoverable", diff --git a/tests-py3.txt b/tests-py3.txt index e37fe7466574..5f54873ffa4a 100644 --- a/tests-py3.txt +++ b/tests-py3.txt @@ -45,6 +45,7 @@ nova.tests.unit.api.openstack.compute.test_security_groups.TestSecurityGroupRule nova.tests.unit.api.openstack.compute.test_security_groups.TestSecurityGroupRulesV21 nova.tests.unit.api.openstack.compute.test_server_actions.ServerActionsControllerTestV2 nova.tests.unit.api.openstack.compute.test_server_actions.ServerActionsControllerTestV21 +nova.tests.unit.api.openstack.compute.test_server_tags.ServerTagsTest nova.tests.unit.api.openstack.compute.test_serversV21.Base64ValidationTest nova.tests.unit.api.openstack.compute.test_serversV21.ServersControllerCreateTest nova.tests.unit.api.openstack.compute.test_serversV21.ServersControllerRebuildInstanceTest