From ead97e6d3314f18142266815816d24a47b7200ee Mon Sep 17 00:00:00 2001 From: Mike Fedosin Date: Sat, 1 Oct 2016 14:17:06 +0300 Subject: [PATCH] Implement 'all' artifact type Change-Id: Ia3bbe4f76af29e269ce25e67a6d2324e1ec57927 --- glare/db/artifact_api.py | 3 +- glare/objects/all.py | 34 ++++ glare/objects/base.py | 54 ++++++ glare/objects/meta/registry.py | 2 +- glare/tests/functional/base.py | 183 ++++++++++++++++++ glare/tests/functional/test_all.py | 96 +++++++++ .../tests/functional/test_sample_artifact.py | 167 +--------------- glare/tests/functional/test_schemas.py | 72 ++----- 8 files changed, 398 insertions(+), 213 deletions(-) create mode 100644 glare/objects/all.py create mode 100644 glare/tests/functional/base.py create mode 100644 glare/tests/functional/test_all.py diff --git a/glare/db/artifact_api.py b/glare/db/artifact_api.py index b2b775d..b63fa16 100644 --- a/glare/db/artifact_api.py +++ b/glare/db/artifact_api.py @@ -57,7 +57,8 @@ class ArtifactAPI(base_api.BaseDBAPI): def list(self, context, filters, marker, limit, sort, latest): session = api.get_session() - filters.append(('type_name', None, 'eq', None, self.type)) + if self.type != 'all': + filters.append(('type_name', None, 'eq', None, self.type)) return api.get_all(context=context, session=session, filters=filters, marker=marker, limit=limit, sort=sort, latest=latest) diff --git a/glare/objects/all.py b/glare/objects/all.py new file mode 100644 index 0000000..b6da231 --- /dev/null +++ b/glare/objects/all.py @@ -0,0 +1,34 @@ +# Copyright (c) 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. + +from oslo_versionedobjects import fields + +from glare.objects import base +from glare.objects.meta import attribute + + +Field = attribute.Attribute.init + + +class All(base.ReadOnlyMixin, base.BaseArtifact): + """Artifact type that allows to get artifacts regardless of their type""" + + fields = { + 'type_name': Field(fields.StringField, + description="Name of artifact type."), + } + + @classmethod + def get_type_name(cls): + return "all" diff --git a/glare/objects/base.py b/glare/objects/base.py index 3ba6b06..f990ed9 100644 --- a/glare/objects/base.py +++ b/glare/objects/base.py @@ -1200,3 +1200,57 @@ class BaseArtifact(base.VersionedObject): 'required': ['name']} return schemas + + +class ReadOnlyMixin(object): + """Mixin that disables all modifying actions on artifacts.""" + + @classmethod + def create(cls, context, values): + raise exception.Forbidden("This type is read only.") + + @classmethod + def update(cls, context, af, values): + raise exception.Forbidden("This type is read only.") + + @classmethod + def get_action_for_updates(cls, context, artifact, updates, registry): + raise exception.Forbidden("This type is read only.") + + @classmethod + def delete(cls, context, af): + raise exception.Forbidden("This type is read only.") + + @classmethod + def activate(cls, context, af, values): + raise exception.Forbidden("This type is read only.") + + @classmethod + def reactivate(cls, context, af, values): + raise exception.Forbidden("This type is read only.") + + @classmethod + def deactivate(cls, context, af, values): + raise exception.Forbidden("This type is read only.") + + @classmethod + def publish(cls, context, af, values): + raise exception.Forbidden("This type is read only.") + + @classmethod + def upload_blob(cls, context, af, field_name, fd, content_type): + raise exception.Forbidden("This type is read only.") + + @classmethod + def upload_blob_dict(cls, context, af, field_name, blob_key, fd, + content_type): + raise exception.Forbidden("This type is read only.") + + @classmethod + def add_blob_location(cls, context, af, field_name, location, blob_meta): + raise exception.Forbidden("This type is read only.") + + @classmethod + def add_blob_dict_location(cls, context, af, field_name, + blob_key, location, blob_meta): + raise exception.Forbidden("This type is read only.") diff --git a/glare/objects/meta/registry.py b/glare/objects/meta/registry.py index 2c51558..5ff0413 100644 --- a/glare/objects/meta/registry.py +++ b/glare/objects/meta/registry.py @@ -103,7 +103,7 @@ class ArtifactRegistry(vo_base.VersionedObjectRegistry): supported_types = [] for module in modules: supported_types.extend(get_subclasses(module, base.BaseArtifact)) - for type_name in CONF.glare.enabled_artifact_types: + for type_name in set(CONF.glare.enabled_artifact_types + ['all']): for af_type in supported_types: if type_name == af_type.get_type_name(): cls._validate_artifact_type(af_type) diff --git a/glare/tests/functional/base.py b/glare/tests/functional/base.py new file mode 100644 index 0000000..e6a68e9 --- /dev/null +++ b/glare/tests/functional/base.py @@ -0,0 +1,183 @@ +# Copyright (c) 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. + +import uuid + +from oslo_serialization import jsonutils +import requests + +from glare.tests import functional + + +def sort_results(lst, target='name'): + return sorted(lst, key=lambda x: x[target]) + + +class TestArtifact(functional.FunctionalTest): + enabled_types = (u'sample_artifact', u'images', u'heat_templates', + u'heat_environments', u'tosca_templates', + u'murano_packages') + + users = { + 'user1': { + 'id': str(uuid.uuid4()), + 'tenant_id': str(uuid.uuid4()), + 'token': str(uuid.uuid4()), + 'role': 'member' + }, + 'user2': { + 'id': str(uuid.uuid4()), + 'tenant_id': str(uuid.uuid4()), + 'token': str(uuid.uuid4()), + 'role': 'member' + }, + 'admin': { + 'id': str(uuid.uuid4()), + 'tenant_id': str(uuid.uuid4()), + 'token': str(uuid.uuid4()), + 'role': 'admin' + }, + 'anonymous': { + 'id': None, + 'tenant_id': None, + 'token': None, + 'role': None + } + } + + def setUp(self): + super(TestArtifact, self).setUp() + + self.set_user('user1') + self.glare_server.deployment_flavor = 'noauth' + + self.glare_server.enabled_artifact_types = ','.join( + self.enabled_types) + self.glare_server.custom_artifact_types_modules = ( + 'glare.tests.functional.sample_artifact') + self.start_servers(**self.__dict__.copy()) + + def tearDown(self): + self.stop_servers() + self._reset_database(self.glare_server.sql_connection) + super(TestArtifact, self).tearDown() + + def _url(self, path): + if 'schemas' in path: + return 'http://127.0.0.1:%d%s' % (self.glare_port, path) + else: + return 'http://127.0.0.1:%d/artifacts%s' % (self.glare_port, path) + + def set_user(self, username): + if username not in self.users: + raise KeyError + self.current_user = username + + def _headers(self, custom_headers=None): + base_headers = { + 'X-Identity-Status': 'Confirmed', + 'X-Auth-Token': self.users[self.current_user]['token'], + 'X-User-Id': self.users[self.current_user]['id'], + 'X-Tenant-Id': self.users[self.current_user]['tenant_id'], + 'X-Project-Id': self.users[self.current_user]['tenant_id'], + 'X-Roles': self.users[self.current_user]['role'], + } + base_headers.update(custom_headers or {}) + return base_headers + + def create_artifact(self, data=None, status=201, + type_name='sample_artifact'): + return self.post('/' + type_name, data or {}, status=status) + + def _check_artifact_method(self, method, url, data=None, status=200, + headers=None): + if not headers: + headers = self._headers() + else: + headers = self._headers(headers) + headers.setdefault("Content-Type", "application/json") + if 'application/json' in headers['Content-Type'] and data is not None: + data = jsonutils.dumps(data) + response = getattr(requests, method)(self._url(url), headers=headers, + data=data) + self.assertEqual(status, response.status_code, response.text) + if status >= 400: + return response.text + if ("application/json" in response.headers["content-type"] or + "application/schema+json" in response.headers["content-type"]): + return jsonutils.loads(response.text) + return response.text + + def post(self, url, data=None, status=201, headers=None): + return self._check_artifact_method("post", url, data, status=status, + headers=headers) + + def get(self, url, status=200, headers=None): + return self._check_artifact_method("get", url, status=status, + headers=headers) + + def delete(self, url, status=204): + response = requests.delete(self._url(url), headers=self._headers()) + self.assertEqual(status, response.status_code, response.text) + return response.text + + def patch(self, url, data, status=200, headers=None): + if headers is None: + headers = {} + if 'Content-Type' not in headers: + headers.update({'Content-Type': 'application/json-patch+json'}) + return self._check_artifact_method("patch", url, data, status=status, + headers=headers) + + def put(self, url, data=None, status=200, headers=None): + return self._check_artifact_method("put", url, data, status=status, + headers=headers) + + # the test cases below are written in accordance with use cases + # each test tries to cover separate use case in Glare + # all code inside each test tries to cover all operators and data + # involved in use case execution + # each tests represents part of artifact lifecycle + # so we can easily define where is the failed code + + make_active = [{"op": "replace", "path": "/status", "value": "active"}] + + def activate_with_admin(self, artifact_id, status=200): + cur_user = self.current_user + self.set_user('admin') + url = '/sample_artifact/%s' % artifact_id + af = self.patch(url=url, data=self.make_active, status=status) + self.set_user(cur_user) + return af + + make_deactivated = [{"op": "replace", "path": "/status", + "value": "deactivated"}] + + def deactivate_with_admin(self, artifact_id, status=200): + cur_user = self.current_user + self.set_user('admin') + url = '/sample_artifact/%s' % artifact_id + af = self.patch(url=url, data=self.make_deactivated, status=status) + self.set_user(cur_user) + return af + + make_public = [{"op": "replace", "path": "/visibility", "value": "public"}] + + def publish_with_admin(self, artifact_id, status=200): + cur_user = self.current_user + self.set_user('admin') + url = '/sample_artifact/%s' % artifact_id + af = self.patch(url=url, data=self.make_public, status=status) + self.set_user(cur_user) + return af diff --git a/glare/tests/functional/test_all.py b/glare/tests/functional/test_all.py new file mode 100644 index 0000000..b0a1240 --- /dev/null +++ b/glare/tests/functional/test_all.py @@ -0,0 +1,96 @@ +# Copyright (c) 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. + +from glare.tests.functional import base + + +class TestAll(base.TestArtifact): + + def test_all(self): + for type_name in self.enabled_types: + if type_name == 'all': + continue + for i in range(3): + for j in range(3): + self.create_artifact( + data={'name': '%s_%d' % (type_name, i), + 'version': '%d' % j, + 'tags': ['tag%s' % i]}, + type_name=type_name) + + # get all possible artifacts + url = '/all?sort=name:asc&limit=100' + res = self.get(url=url, status=200)['all'] + from pprint import pformat + self.assertEqual(54, len(res), pformat(res)) + + # get artifacts with latest versions + url = '/all?version=latest&sort=name:asc' + res = self.get(url=url, status=200)['all'] + self.assertEqual(18, len(res)) + for art in res: + self.assertEqual('2.0.0', art['version']) + + # get images only + url = '/all?type_name=images&sort=name:asc' + res = self.get(url=url, status=200)['all'] + self.assertEqual(9, len(res)) + for art in res: + self.assertEqual('images', art['type_name']) + + # get images and heat_templates + url = '/all?type_name=in:images,heat_templates&sort=name:asc' + res = self.get(url=url, status=200)['all'] + self.assertEqual(18, len(res)) + for art in res: + self.assertIn(art['type_name'], ('images', 'heat_templates')) + + def test_all_readonlyness(self): + self.create_artifact(data={'name': 'all'}, type_name='all', status=403) + art = self.create_artifact(data={'name': 'image'}, type_name='images') + + url = '/all/%s' % art['id'] + + headers = {'Content-Type': 'application/octet-stream'} + # upload to 'all' is forbidden + self.put(url=url + '/icon', data='data', status=403, + headers=headers) + + # update 'all' is forbidden + data = [{ + "op": "replace", + "path": "/description", + "value": "text" + }] + self.patch(url=url, data=data, status=403) + + # activation is forbidden + data = [{ + "op": "replace", + "path": "/status", + "value": "active" + }] + self.patch(url=url, data=data, status=403) + + # publishing is forbidden + data = [{ + "op": "replace", + "path": "/visibility", + "value": "public" + }] + self.patch(url=url, data=data, status=403) + + # get is okay + new_art = self.get(url=url) + self.assertEqual(new_art['id'], art['id']) diff --git a/glare/tests/functional/test_sample_artifact.py b/glare/tests/functional/test_sample_artifact.py index 67e5de7..f4f6f8c 100644 --- a/glare/tests/functional/test_sample_artifact.py +++ b/glare/tests/functional/test_sample_artifact.py @@ -17,168 +17,15 @@ import hashlib import uuid from oslo_serialization import jsonutils -import requests -from glare.tests import functional +from glare.tests.functional import base def sort_results(lst, target='name'): return sorted(lst, key=lambda x: x[target]) -class TestArtifact(functional.FunctionalTest): - - users = { - 'user1': { - 'id': str(uuid.uuid4()), - 'tenant_id': str(uuid.uuid4()), - 'token': str(uuid.uuid4()), - 'role': 'member' - }, - 'user2': { - 'id': str(uuid.uuid4()), - 'tenant_id': str(uuid.uuid4()), - 'token': str(uuid.uuid4()), - 'role': 'member' - }, - 'admin': { - 'id': str(uuid.uuid4()), - 'tenant_id': str(uuid.uuid4()), - 'token': str(uuid.uuid4()), - 'role': 'admin' - }, - 'anonymous': { - 'id': None, - 'tenant_id': None, - 'token': None, - 'role': None - } - } - - def setUp(self): - super(TestArtifact, self).setUp() - self.set_user('user1') - self.glare_server.deployment_flavor = 'noauth' - self.glare_server.enabled_artifact_types = 'sample_artifact' - self.glare_server.custom_artifact_types_modules = ( - 'glare.tests.functional.sample_artifact') - self.start_servers(**self.__dict__.copy()) - - def tearDown(self): - self.stop_servers() - self._reset_database(self.glare_server.sql_connection) - super(TestArtifact, self).tearDown() - - def _url(self, path): - if 'schemas' in path: - return 'http://127.0.0.1:%d%s' % (self.glare_port, path) - else: - return 'http://127.0.0.1:%d/artifacts%s' % (self.glare_port, path) - - def set_user(self, username): - if username not in self.users: - raise KeyError - self.current_user = username - - def _headers(self, custom_headers=None): - base_headers = { - 'X-Identity-Status': 'Confirmed', - 'X-Auth-Token': self.users[self.current_user]['token'], - 'X-User-Id': self.users[self.current_user]['id'], - 'X-Tenant-Id': self.users[self.current_user]['tenant_id'], - 'X-Project-Id': self.users[self.current_user]['tenant_id'], - 'X-Roles': self.users[self.current_user]['role'], - } - base_headers.update(custom_headers or {}) - return base_headers - - def create_artifact(self, data=None, status=201): - return self.post('/sample_artifact', data or {}, status=status) - - def _check_artifact_method(self, method, url, data=None, status=200, - headers=None): - if not headers: - headers = self._headers() - else: - headers = self._headers(headers) - headers.setdefault("Content-Type", "application/json") - if 'application/json' in headers['Content-Type'] and data is not None: - data = jsonutils.dumps(data) - response = getattr(requests, method)(self._url(url), headers=headers, - data=data) - self.assertEqual(status, response.status_code, response.text) - if status >= 400: - return response.text - if ("application/json" in response.headers["content-type"] or - "application/schema+json" in response.headers["content-type"]): - return jsonutils.loads(response.text) - return response.text - - def post(self, url, data=None, status=201, headers=None): - return self._check_artifact_method("post", url, data, status=status, - headers=headers) - - def get(self, url, status=200, headers=None): - return self._check_artifact_method("get", url, status=status, - headers=headers) - - def delete(self, url, status=204): - response = requests.delete(self._url(url), headers=self._headers()) - self.assertEqual(status, response.status_code, response.text) - return response.text - - def patch(self, url, data, status=200, headers=None): - if headers is None: - headers = {} - if 'Content-Type' not in headers: - headers.update({'Content-Type': 'application/json-patch+json'}) - return self._check_artifact_method("patch", url, data, status=status, - headers=headers) - - def put(self, url, data=None, status=200, headers=None): - return self._check_artifact_method("put", url, data, status=status, - headers=headers) - - # the test cases below are written in accordance with use cases - # each test tries to cover separate use case in Glare - # all code inside each test tries to cover all operators and data - # involved in use case execution - # each tests represents part of artifact lifecycle - # so we can easily define where is the failed code - - make_active = [{"op": "replace", "path": "/status", "value": "active"}] - - def activate_with_admin(self, artifact_id, status=200): - cur_user = self.current_user - self.set_user('admin') - url = '/sample_artifact/%s' % artifact_id - af = self.patch(url=url, data=self.make_active, status=status) - self.set_user(cur_user) - return af - - make_deactivated = [{"op": "replace", "path": "/status", - "value": "deactivated"}] - - def deactivate_with_admin(self, artifact_id, status=200): - cur_user = self.current_user - self.set_user('admin') - url = '/sample_artifact/%s' % artifact_id - af = self.patch(url=url, data=self.make_deactivated, status=status) - self.set_user(cur_user) - return af - - make_public = [{"op": "replace", "path": "/visibility", "value": "public"}] - - def publish_with_admin(self, artifact_id, status=200): - cur_user = self.current_user - self.set_user('admin') - url = '/sample_artifact/%s' % artifact_id - af = self.patch(url=url, data=self.make_public, status=status) - self.set_user(cur_user) - return af - - -class TestList(TestArtifact): +class TestList(base.TestArtifact): def test_list_marker_and_limit(self): # Create artifacts art_list = [self.create_artifact({'name': 'name%s' % i, @@ -806,7 +653,7 @@ class TestList(TestArtifact): self.assertEqual(response_url, result['first']) -class TestBlobs(TestArtifact): +class TestBlobs(base.TestArtifact): def test_blob_dicts(self): # Getting empty artifact list url = '/sample_artifact' @@ -1005,7 +852,7 @@ class TestBlobs(TestArtifact): status=400, headers=headers) -class TestTags(TestArtifact): +class TestTags(base.TestArtifact): def test_tags(self): # Create artifact art = self.create_artifact({'name': 'name5', @@ -1064,7 +911,7 @@ class TestTags(TestArtifact): self.patch(url=url, data=patch, status=400) -class TestArtifactOps(TestArtifact): +class TestArtifactOps(base.TestArtifact): def test_create(self): """All tests related to artifact creation""" # check that cannot create artifact for non-existent artifact type @@ -1346,7 +1193,7 @@ class TestArtifactOps(TestArtifact): self.assertEqual("active", deactive_art["status"]) -class TestUpdate(TestArtifact): +class TestUpdate(base.TestArtifact): def test_update_artifact_before_activate(self): """Test updates for artifact before activation""" # create artifact to update @@ -2262,7 +2109,7 @@ class TestUpdate(TestArtifact): self.patch(url=url, data=data, status=400) -class TestDependencies(TestArtifact): +class TestDependencies(base.TestArtifact): def test_manage_dependencies(self): some_af = self.create_artifact(data={"name": "test_af"}) dep_af = self.create_artifact(data={"name": "test_dep_af"}) diff --git a/glare/tests/functional/test_schemas.py b/glare/tests/functional/test_schemas.py index 901deb0..66eba33 100644 --- a/glare/tests/functional/test_schemas.py +++ b/glare/tests/functional/test_schemas.py @@ -15,11 +15,8 @@ import jsonschema -from oslo_serialization import jsonutils -import requests - from glare.common import utils -from glare.tests import functional +from glare.tests.functional import base fixture_base_props = { u'activated_at': { @@ -231,10 +228,6 @@ fixture_base_props = { u'type': u'string'} } -enabled_artifact_types = ( - u'sample_artifact', u'images', u'heat_templates', - u'heat_environments', u'tosca_templates', u'murano_packages') - def generate_type_props(props): props.update(fixture_base_props) @@ -976,61 +969,38 @@ fixtures = { u'required': [u'name'], u'version': u'1.0', u'title': u'Artifact type heat_environments of version 1.0', + u'type': u'object'}, + u'all': { + u'name': u'all', + u'properties': generate_type_props({ + u'type_name': {u'description': u'Name of artifact type.', + u'filter_ops': [u'eq', u'neq', u'in'], + u'maxLength': 255, + u'type': [u'string', u'null']}, + + }), + u'required': [u'name'], + u'version': u'1.0', + u'title': u'Artifact type all of version 1.0', u'type': u'object'} } -class TestSchemas(functional.FunctionalTest): - - def setUp(self): - super(TestSchemas, self).setUp() - self.glare_server.deployment_flavor = 'noauth' - - self.glare_server.enabled_artifact_types = ','.join( - enabled_artifact_types) - self.glare_server.custom_artifact_types_modules = ( - 'glare.tests.functional.sample_artifact') - self.start_servers(**self.__dict__.copy()) - - def tearDown(self): - self.stop_servers() - self._reset_database(self.glare_server.sql_connection) - super(TestSchemas, self).tearDown() - - def _url(self, path): - return 'http://127.0.0.1:%d%s' % (self.glare_port, path) - - def _check_artifact_method(self, url, status=200): - headers = { - 'X-Identity-Status': 'Confirmed', - } - response = requests.get(self._url(url), headers=headers) - self.assertEqual(status, response.status_code, response.text) - if status >= 400: - return response.text - if ("application/json" in response.headers["content-type"] or - "application/schema+json" in response.headers["content-type"]): - return jsonutils.loads(response.text) - return response.text - - def get(self, url, status=200, headers=None): - return self._check_artifact_method(url, status=status) - +class TestSchemas(base.TestArtifact): def test_schemas(self): - - # Get list schemas of artifacts - result = self.get(url='/schemas') - self.assertEqual(fixtures, result['schemas'], utils.DictDiffer( - result['schemas'], fixtures)) - # Get schemas for specific artifact type - for at in enabled_artifact_types: + for at in self.enabled_types: result = self.get(url='/schemas/%s' % at) self.assertEqual(fixtures[at], result['schemas'][at], utils.DictDiffer( result['schemas'][at]['properties'], fixtures[at]['properties'])) + # Get list schemas of artifacts + result = self.get(url='/schemas') + self.assertEqual(fixtures, result['schemas'], utils.DictDiffer( + result['schemas'], fixtures)) + # Get schema of sample_artifact result = self.get(url='/schemas/sample_artifact') self.assertEqual(fixtures['sample_artifact'],