Added server tags controller

Added new controller which allows the following:
- add tag to the server
- replace set of server tags with new set of tags
- get list of tags for server
- check if tag exists on a server
- remove specified tag from server
- remove all tags from server

Functional tests and annotations "@wsgi.Controller.api_version(*)"
for controller methods will be added in next patch
with creation of new API microversion.

APIImpact

Implements: blueprint tag-instances

Change-Id: Ibc44228aeae94c17353af7fccfcfb2c11b2e9190
This commit is contained in:
Sergey Nikitin 2016-01-15 17:11:05 +03:00
parent 0a14f0b296
commit bfe8e7484d
10 changed files with 549 additions and 0 deletions

View File

@ -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": "@",

View File

@ -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
}

View File

@ -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]

View File

@ -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)

View File

@ -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.

View File

@ -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):

View File

@ -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)

View File

@ -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": "",

View File

@ -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",

View File

@ -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