From 520e2306905f9afa084b4530e3f2f4e21ced8af0 Mon Sep 17 00:00:00 2001 From: Viacheslav Valyavskiy Date: Thu, 10 Nov 2016 21:53:10 +0300 Subject: [PATCH] Add tag API Change-Id: I4e8325866ef63e424d5c6a93138e731001731568 Implements: blueprint role-decomposition --- nailgun/nailgun/api/v1/handlers/tag.py | 139 ++++++++++++++ nailgun/nailgun/api/v1/urls.py | 9 + .../api/v1/validators/json_schema/tag.py | 39 ++++ nailgun/nailgun/api/v1/validators/tag.py | 45 +++++ nailgun/nailgun/objects/cluster.py | 25 +++ nailgun/nailgun/objects/release.py | 29 +++ nailgun/nailgun/objects/serializers/tag.py | 26 +++ nailgun/nailgun/test/base.py | 59 ++++++ .../nailgun/test/integration/test_tag_api.py | 178 ++++++++++++++++++ 9 files changed, 549 insertions(+) create mode 100644 nailgun/nailgun/api/v1/handlers/tag.py create mode 100644 nailgun/nailgun/api/v1/validators/json_schema/tag.py create mode 100644 nailgun/nailgun/api/v1/validators/tag.py create mode 100644 nailgun/nailgun/objects/serializers/tag.py create mode 100644 nailgun/nailgun/test/integration/test_tag_api.py diff --git a/nailgun/nailgun/api/v1/handlers/tag.py b/nailgun/nailgun/api/v1/handlers/tag.py new file mode 100644 index 0000000000..a4fd56f749 --- /dev/null +++ b/nailgun/nailgun/api/v1/handlers/tag.py @@ -0,0 +1,139 @@ +# -*- coding: utf-8 -*- + +# Copyright 2015 Mirantis, Inc. +# +# 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 six + +from nailgun.api.v1.handlers import base +from nailgun.api.v1.handlers.base import handle_errors +from nailgun.api.v1.handlers.base import serialize +from nailgun.api.v1.handlers.base import validate +from nailgun.api.v1.validators.tag import TagValidator +from nailgun import errors +from nailgun import objects +from nailgun.objects.serializers.tag import TagSerializer + + +class TagMixIn(object): + + def _get_object_or_404(self, obj_type, obj_id): + obj_cls = { + 'releases': objects.Release, + 'clusters': objects.Cluster, + }[obj_type] + return obj_cls, self.get_object_or_404(obj_cls, obj_id) + + +class TagHandler(base.SingleHandler, TagMixIn): + + validator = TagValidator + + def _check_tag(self, obj_cls, obj, tag_name): + if tag_name not in obj_cls.get_own_tags(obj): + raise self.http( + 404, + "Tag '{}' is not found for the {} {}".format( + tag_name, obj_cls.__name__.lower(), obj.id)) + + @handle_errors + @validate + @serialize + def GET(self, obj_type, obj_id, tag_name): + """Retrieve tag + + :http: + * 200 (OK) + * 404 (no such object found) + """ + obj_cls, obj = self._get_object_or_404(obj_type, obj_id) + self._check_tag(obj_cls, obj, tag_name) + return TagSerializer.serialize_from_obj(obj_cls, obj, tag_name) + + @handle_errors + @validate + @serialize + def PUT(self, obj_type, obj_id, tag_name): + """Update tag + + :http: + * 200 (OK) + * 400 (wrong data specified) + * 404 (no such object found) + """ + obj_cls, obj = self._get_object_or_404(obj_type, obj_id) + self._check_tag(obj_cls, obj, tag_name) + data = self.checked_data( + self.validator.validate_update, instance_cls=obj_cls, instance=obj) + obj_cls.update_tag(obj, data) + return TagSerializer.serialize_from_obj(obj_cls, obj, tag_name) + + @handle_errors + def DELETE(self, obj_type, obj_id, tag_name): + """Remove tag + + :http: + * 204 (object successfully deleted) + * 400 (cannot delete object) + * 404 (no such object found) + """ + obj_cls, obj = self._get_object_or_404(obj_type, obj_id) + self._check_tag(obj_cls, obj, tag_name) + obj_cls.remove_tag(obj, tag_name) + raise self.http(204) + + +class TagCollectionHandler(base.CollectionHandler, TagMixIn): + + validator = TagValidator + + @handle_errors + @validate + def POST(self, obj_type, obj_id): + """Create tag for release or cluster + + :http: + * 201 (object successfully created) + * 400 (invalid object data specified) + * 409 (object with such parameters already exists) + * 404 (no such object found) + """ + obj_cls, obj = self._get_object_or_404(obj_type, obj_id) + try: + data = self.checked_data( + self.validator.validate_create, + instance_cls=obj_cls, + instance=obj) + except errors.AlreadyExists as exc: + raise self.http(409, exc.message) + + tag_name = data['name'] + obj_cls.update_tag(obj, data) + raise self.http( + 201, TagSerializer.serialize_from_obj(obj_cls, obj, tag_name)) + + @handle_errors + @validate + @serialize + def GET(self, obj_type, obj_id): + """Retrieve tag list of release or cluster + + :http: + * 200 (OK) + * 404 (no such object found) + """ + obj_cls, obj = self._get_object_or_404(obj_type, obj_id) + tag_names = six.iterkeys(obj_cls.get_tags_metadata(obj)) + return [TagSerializer.serialize_from_obj(obj_cls, obj, tag_name) + for tag_name in tag_names] diff --git a/nailgun/nailgun/api/v1/urls.py b/nailgun/nailgun/api/v1/urls.py index 55a927bbaf..d4249db6f9 100644 --- a/nailgun/nailgun/api/v1/urls.py +++ b/nailgun/nailgun/api/v1/urls.py @@ -112,6 +112,9 @@ from nailgun.api.v1.handlers.release import ReleaseNetworksHandler from nailgun.api.v1.handlers.role import RoleCollectionHandler from nailgun.api.v1.handlers.role import RoleHandler +from nailgun.api.v1.handlers.tag import TagCollectionHandler +from nailgun.api.v1.handlers.tag import TagHandler + from nailgun.api.v1.handlers.tasks import TaskCollectionHandler from nailgun.api.v1.handlers.tasks import TaskHandler @@ -171,6 +174,12 @@ urls = ( '(?P[a-zA-Z0-9-_]+)/?$', RoleHandler, + r'/(?Preleases|clusters)/(?P\d+)/tags/?$', + TagCollectionHandler, + r'/(?Preleases|clusters)/(?P\d+)/tags/' + '(?P[a-zA-Z0-9-_]+)/?$', + TagHandler, + r'/releases/(?P\d+)/deployment_graphs/?$', ReleaseDeploymentGraphCollectionHandler, r'/releases/(?P\d+)/deployment_graphs/' diff --git a/nailgun/nailgun/api/v1/validators/json_schema/tag.py b/nailgun/nailgun/api/v1/validators/json_schema/tag.py new file mode 100644 index 0000000000..061e54dbce --- /dev/null +++ b/nailgun/nailgun/api/v1/validators/json_schema/tag.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +# Copyright 2016 Mirantis, Inc. +# +# 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_META_INFO = { + "type": "object", + "required": ["has_primary"], + "properties": { + "has_primary": { + "type": "boolean", + "description": ("During orchestration this role" + " will be splitted into primary-role and role.") + } + } +} + + +SCHEMA = { + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "Tag", + "description": "Serialized Tag object", + "type": "object", + "required": ['name'], + "properties": { + "name": {"type": "string", "pattern": "^[a-zA-Z0-9_-]+$"}, + "meta": TAG_META_INFO + } +} diff --git a/nailgun/nailgun/api/v1/validators/tag.py b/nailgun/nailgun/api/v1/validators/tag.py new file mode 100644 index 0000000000..f0ab6ba66f --- /dev/null +++ b/nailgun/nailgun/api/v1/validators/tag.py @@ -0,0 +1,45 @@ +# -*- coding: utf-8 -*- +# Copyright 2015 Mirantis, Inc. +# +# 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 nailgun.api.v1.validators.base import BasicValidator +from nailgun.api.v1.validators.json_schema import tag +from nailgun import errors + + +class TagValidator(BasicValidator): + + @classmethod + def validate(cls, data, instance=None): + parsed = super(TagValidator, cls).validate(data) + cls.validate_schema(parsed, tag.SCHEMA) + return parsed + + @classmethod + def validate_update(cls, data, instance_cls, instance): + parsed = cls.validate(data, instance=instance) + return parsed + + @classmethod + def validate_create(cls, data, instance_cls, instance): + parsed = cls.validate(data, instance=instance) + + tag_name = parsed['name'] + if tag_name in instance_cls.get_own_tags(instance): + raise errors.AlreadyExists( + "Tag with name '{}' already " + "exists for {} {}".format( + tag_name, instance_cls.__name__.lower(), instance.id)) + + return parsed diff --git a/nailgun/nailgun/objects/cluster.py b/nailgun/nailgun/objects/cluster.py index 8bcc2872f2..458ec7bb6c 100644 --- a/nailgun/nailgun/objects/cluster.py +++ b/nailgun/nailgun/objects/cluster.py @@ -408,6 +408,27 @@ class Cluster(NailgunObject): instance.volumes_metadata.changed() return bool(result) + @classmethod + def update_tag(cls, instance, tag): + """Update existing Cluster instance with specified tag. + + Previous ones are deleted. + + :param instance: a Cluster instance + :param role: a tag dict + :returns: None + """ + instance.tags_metadata[tag['name']] = tag['meta'] + + @classmethod + def remove_tag(cls, instance, tag_name): + for role, meta in six.iteritems(cls.get_own_roles(instance)): + tags = meta.get('tags', []) + if tag_name in tags: + tags.remove(tag_name) + instance.roles_metadata.changed() + return bool(instance.tags_metadata.pop(tag_name, None)) + @classmethod def _create_public_map(cls, instance, roles_metadata=None): if instance.network_config.configuration_template is not None: @@ -824,6 +845,10 @@ class Cluster(NailgunObject): def get_own_roles(cls, instance): return instance.roles_metadata + @classmethod + def get_own_tags(cls, instance): + return instance.tags_metadata + @classmethod def set_primary_tag(cls, instance, nodes, tag): """Method for assigning primary attribute for specific tag. diff --git a/nailgun/nailgun/objects/release.py b/nailgun/nailgun/objects/release.py index a0c387ff60..04453dce71 100644 --- a/nailgun/nailgun/objects/release.py +++ b/nailgun/nailgun/objects/release.py @@ -139,6 +139,27 @@ class Release(NailgunObject): instance.volumes_metadata.changed() return bool(result) + @classmethod + def update_tag(cls, instance, tag): + """Update existing Cluster instance with specified tag. + + Previous ones are deleted. + + :param instance: a Cluster instance + :param role: a tag dict + :returns: None + """ + instance.tags_metadata[tag['name']] = tag['meta'] + + @classmethod + def remove_tag(cls, instance, tag_name): + for role, meta in six.iteritems(cls.get_own_roles(instance)): + tags = meta.get('tags', []) + if tag_name in tags: + tags.remove(tag_name) + instance.roles_metadata.changed() + return bool(instance.tags_metadata.pop(tag_name, None)) + @classmethod def is_deployable(cls, instance): """Returns whether a given release deployable or not. @@ -317,6 +338,14 @@ class Release(NailgunObject): def get_own_roles(cls, instance): return instance.roles_metadata + @classmethod + def get_tags_metadata(cls, instance): + return cls.get_own_tags(instance) + + @classmethod + def get_own_tags(cls, instance): + return instance.tags_metadata + @classmethod def _check_relation(cls, a, b, relation): """Helper function to check commutative property for relations""" diff --git a/nailgun/nailgun/objects/serializers/tag.py b/nailgun/nailgun/objects/serializers/tag.py new file mode 100644 index 0000000000..209d8a78c1 --- /dev/null +++ b/nailgun/nailgun/objects/serializers/tag.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- + +# Copyright 2015 Mirantis, Inc. +# +# 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 nailgun.objects.serializers.base import BasicSerializer + + +class TagSerializer(BasicSerializer): + + @classmethod + def serialize_from_obj(cls, obj_cls, obj, tag_name): + tag_meta = obj_cls.get_tags_metadata(obj)[tag_name] + + return {'name': tag_name, 'meta': tag_meta} diff --git a/nailgun/nailgun/test/base.py b/nailgun/nailgun/test/base.py index 31ae2d6f23..c1fae07803 100644 --- a/nailgun/nailgun/test/base.py +++ b/nailgun/nailgun/test/base.py @@ -262,6 +262,65 @@ class EnvironmentManager(object): expect_errors=expect_errors ) + def get_all_tags(self, obj_type, obj_id, expect_errors=False): + return self.app.get( + reverse( + 'TagCollectionHandler', + kwargs={'obj_id': obj_id, 'obj_type': obj_type} + ), + headers=self.default_headers, + expect_errors=expect_errors + ) + + def get_tag(self, obj_type, obj_id, tag_name, expect_errors=False): + return self.app.get( + reverse( + 'TagHandler', + kwargs={'obj_id': obj_id, + 'obj_type': obj_type, + 'tag_name': tag_name} + ), + headers=self.default_headers, + expect_errors=expect_errors + ) + + def update_tag(self, obj_type, obj_id, tag_name, data, + expect_errors=False): + return self.app.put( + reverse( + 'TagHandler', + kwargs={'obj_id': obj_id, + 'obj_type': obj_type, + 'tag_name': tag_name} + ), + jsonutils.dumps(data), + headers=self.default_headers, + expect_errors=expect_errors + ) + + def delete_tag(self, obj_type, obj_id, tag_name, expect_errors=False): + return self.app.delete( + reverse( + 'TagHandler', + kwargs={'obj_id': obj_id, + 'obj_type': obj_type, + 'tag_name': tag_name} + ), + headers=self.default_headers, + expect_errors=expect_errors + ) + + def create_tag(self, obj_type, obj_id, data, expect_errors=False): + return self.app.post( + reverse( + 'TagCollectionHandler', + kwargs={'obj_id': obj_id, 'obj_type': obj_type} + ), + jsonutils.dumps(data), + headers=self.default_headers, + expect_errors=expect_errors + ) + def create_cluster(self, api=True, exclude=None, **kwargs): cluster_data = { 'name': 'cluster-api-' + str(randint(0, 1000000)), diff --git a/nailgun/nailgun/test/integration/test_tag_api.py b/nailgun/nailgun/test/integration/test_tag_api.py new file mode 100644 index 0000000000..cc719b41ff --- /dev/null +++ b/nailgun/nailgun/test/integration/test_tag_api.py @@ -0,0 +1,178 @@ +# -*- coding: utf-8 -*- + +# Copyright 2015 Mirantis, Inc. +# +# 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 nailgun.test import base + + +class TestReleaseTagsHandler(base.BaseTestCase): + + def setUp(self): + super(TestReleaseTagsHandler, self).setUp() + self.release = self.env.create_release() + self.tag_data = {'name': 'my_tag', 'meta': {'has_primary': False}} + + def test_get_all_tags(self): + owner_type, owner_id = 'releases', self.release.id + resp = self.env.get_all_tags(owner_type, owner_id) + + self.assertEqual( + len(self.release.tags_metadata.keys()), + len(resp.json)) + + def test_create_tag(self): + owner_type, owner_id = 'releases', self.release.id + resp = self.env.create_tag(owner_type, owner_id, self.tag_data) + self.assertEqual(resp.json['meta'], self.tag_data['meta']) + + resp = self.env.get_all_tags(owner_type, owner_id) + + created_tag = next(( + tag + for tag in resp.json if tag['name'] == self.tag_data['name'])) + self.assertEqual(created_tag, self.tag_data) + + def test_update_tag(self): + has_primary = True + owner_type, owner_id = 'releases', self.release.id + + resp = self.env.create_tag(owner_type, owner_id, self.tag_data) + + data = resp.json + data['meta']['has_primary'] = has_primary + + resp = self.env.update_tag(owner_type, owner_id, data['name'], data) + self.assertTrue(resp.json['meta']['has_primary']) + + def test_update_tag_not_present(self): + owner_type, owner_id = 'releases', self.release.id + self.env.create_tag(owner_type, owner_id, self.tag_data) + tag_name = 'blah_tag' + resp = self.env.update_tag(owner_type, + owner_id, + tag_name, + self.tag_data, + expect_errors=True) + self.assertEqual(404, resp.status_code) + self.assertIn('is not found for the release', resp.body) + + def test_delete_tag(self): + owner_type, owner_id = 'releases', self.release.id + self.env.create_tag(owner_type, owner_id, self.tag_data) + delete_resp = self.env.delete_tag( + owner_type, owner_id, self.tag_data['name']) + + self.assertEqual(delete_resp.status_code, 204) + + def test_delete_tag_not_present(self): + owner_type, owner_id = 'releases', self.release.id + self.env.create_tag(owner_type, owner_id, self.tag_data) + tag_name = 'blah_tag' + delete_resp = self.env.delete_tag( + owner_type, owner_id, tag_name, expect_errors=True) + self.assertEqual(delete_resp.status_code, 404) + self.assertIn('is not found for the release', delete_resp.body) + + def test_get_tag(self): + owner_type, owner_id = 'releases', self.release.id + self.env.create_tag(owner_type, owner_id, self.tag_data) + tag = self.env.get_tag(owner_type, owner_id, self.tag_data['name']) + + self.assertEqual(tag.status_code, 200) + self.assertEqual(tag.json['name'], self.tag_data['name']) + + def test_get_tag_not_present(self): + owner_type, owner_id = 'releases', self.release.id + self.env.create_tag(owner_type, owner_id, self.tag_data) + tag_name = 'blah_tag' + resp = self.env.get_tag( + owner_type, owner_id, tag_name, expect_errors=True) + self.assertEqual(resp.status_code, 404) + self.assertIn('is not found for the release', resp.body) + + def test_create_tag_with_special_symbols(self): + owner_type, owner_id = 'releases', self.release.id + self.tag_data['name'] = '@#$%^&*()' + resp = self.env.create_tag( + owner_type, owner_id, self.tag_data, expect_errors=True) + + self.assertEqual(resp.status_code, 400) + + +class TestClusterTagsHandler(base.BaseTestCase): + + def setUp(self): + super(TestClusterTagsHandler, self).setUp() + self.cluster = self.env.create_cluster(api=False) + self.tag_data = {'name': 'my_tag', 'meta': {'has_primary': False}} + + def test_get_all_tags(self): + owner_type, owner_id = 'clusters', self.cluster.id + resp = self.env.get_all_tags(owner_type, owner_id) + + self.assertEqual( + len(self.cluster.release.tags_metadata.keys() + + self.cluster.tags_metadata.keys()), + len(resp.json)) + + def test_create_tag(self): + owner_type, owner_id = 'clusters', self.cluster.id + resp = self.env.create_tag(owner_type, owner_id, self.tag_data) + self.assertEqual(resp.json['meta'], self.tag_data['meta']) + + resp = self.env.get_all_tags(owner_type, owner_id) + + created_tag = next(( + tag + for tag in resp.json if tag['name'] == self.tag_data['name'])) + self.assertEqual(created_tag, self.tag_data) + + def test_update_tag(self): + changed_name = 'Another name' + owner_type, owner_id = 'clusters', self.cluster.id + + resp = self.env.create_tag(owner_type, owner_id, self.tag_data) + + data = resp.json + data['meta']['name'] = changed_name + + resp = self.env.update_tag(owner_type, owner_id, data['name'], data) + self.assertEqual(resp.json['meta']['name'], changed_name) + + def test_get_tag(self): + owner_type, owner_id = 'clusters', self.cluster.id + self.env.create_tag(owner_type, owner_id, self.tag_data) + tag = self.env.get_tag(owner_type, owner_id, self.tag_data['name']) + + self.assertEqual(tag.status_code, 200) + self.assertEqual(tag.json['name'], self.tag_data['name']) + + def test_delete_tag(self): + owner_type, owner_id = 'clusters', self.cluster.id + self.env.create_tag(owner_type, owner_id, self.tag_data) + delete_resp = self.env.delete_tag( + owner_type, owner_id, self.tag_data['name']) + + self.assertEqual(delete_resp.status_code, 204) + + def test_error_tag_not_present(self): + owner_type, owner_id = 'clusters', self.cluster.id + tag_name = 'blah_tag' + resp = self.env.get_tag( + owner_type, owner_id, tag_name, expect_errors=True) + + self.assertEqual(resp.status_code, 404) + self.assertIn("is not found for the cluster", + resp.json_body['message'])