[feat] DECKHAND-36 Revision tagging API

This commit adds an additional attribute called `tags` to each
Revision DB model. This allows Revisions to be tagged with whatever
arbitrary tag/tag data a service chooses to identify a revision by.

This commit:
  - creates a new DB model called `RevisionTag`
  - adds the following endpoints:
     * POST /api/v1.0/revisions/{revision_id}/tags/{tag} (create a tag)
     * GET /api/v1.0/revisions/tags/{tag} (show tag details)
     * GET /api/v1.0/revisions/{revision_id}/tags (list revision tags)
     * DELETE /api/v1.0/revisions/{revision_id}/tags/{tag} (delete a tag)
     * DELETE /api/v1.0/revisions/{revision_id}/tags (delete all tags)
  - adds appropriate unit test coverage for the changes
  - adds functional testing for each API endpoint

Change-Id: I49a7155ef5aa274c3a85ff6f8b85951f155a4b92
This commit is contained in:
Felipe Monteiro 2017-08-07 20:40:57 +01:00
parent c19309f347
commit 7b0a69b39a
20 changed files with 665 additions and 45 deletions

View File

@ -159,7 +159,7 @@ Document creation can be tested locally using (from root deckhand directory):
.. code-block:: console
$ curl -i -X POST localhost:9000/api/v1.0/documents \
$ curl -i -X PUT localhost:9000/api/v1.0/bucket/{bucket_name}/documents \
-H "Content-Type: application/x-yaml" \
--data-binary "@deckhand/tests/unit/resources/sample_document.yaml"

View File

@ -23,6 +23,7 @@ from deckhand.control import base
from deckhand.control import buckets
from deckhand.control import middleware
from deckhand.control import revision_documents
from deckhand.control import revision_tags
from deckhand.control import revisions
from deckhand.control import secrets
from deckhand.db.sqlalchemy import api as db_api
@ -61,10 +62,13 @@ def __setup_db():
def _get_routing_map():
ROUTING_MAP = {
'/api/v1.0/bucket/.+/documents': ['PUT'],
'/api/v1.0/bucket/[A-za-z0-9\-]+/documents': ['PUT'],
'/api/v1.0/revisions': ['GET', 'DELETE'],
'/api/v1.0/revisions/.+': ['GET'],
'/api/v1.0/revisions/documents': ['GET']
'/api/v1.0/revisions/[A-za-z0-9\-]+': ['GET'],
'/api/v1.0/revisions/[A-za-z0-9\-]+/tags': ['GET', 'DELETE'],
'/api/v1.0/revisions/[A-za-z0-9\-]+/tags/[A-za-z0-9\-]+': [
'GET', 'POST', 'DELETE'],
'/api/v1.0/revisions/[A-za-z0-9\-]+/documents': ['GET']
}
for route in ROUTING_MAP.keys():
@ -95,7 +99,10 @@ def start_api(state_manager=None):
('revisions', revisions.RevisionsResource()),
('revisions/{revision_id}', revisions.RevisionsResource()),
('revisions/{revision_id}/documents',
revision_documents.RevisionDocumentsResource()),
revision_documents.RevisionDocumentsResource()),
('revisions/{revision_id}/tags', revision_tags.RevisionTagsResource()),
('revisions/{revision_id}/tags/{tag}',
revision_tags.RevisionTagsResource()),
# TODO(fmontei): remove in follow-up commit.
('secrets', secrets.SecretsResource())
]

View File

@ -17,8 +17,6 @@ import yaml
import falcon
from oslo_context import context
from oslo_log import log as logging
from oslo_serialization import jsonutils as json
import six
LOG = logging.getLogger(__name__)
@ -38,12 +36,6 @@ class BaseResource(object):
resp.headers['Allow'] = ','.join(allowed_methods)
resp.status = falcon.HTTP_200
def return_error(self, resp, status_code, message="", retry=False):
resp.body = json.dumps(
{'type': 'error', 'message': six.text_type(message),
'retry': retry})
resp.status = status_code
def to_yaml_body(self, dict_body):
"""Converts JSON body into YAML response body.

View File

@ -42,7 +42,7 @@ class BucketsResource(api_base.BaseResource):
error_msg = ("Could not parse the document into YAML data. "
"Details: %s." % e)
LOG.error(error_msg)
return self.return_error(resp, falcon.HTTP_400, message=error_msg)
raise falcon.HTTPBadRequest(description=e.format_message())
# All concrete documents in the payload must successfully pass their
# JSON schema validations. Otherwise raise an error.
@ -50,15 +50,16 @@ class BucketsResource(api_base.BaseResource):
validation_policies = document_validation.DocumentValidation(
documents).validate_all()
except (deckhand_errors.InvalidDocumentFormat) as e:
return self.return_error(resp, falcon.HTTP_400, message=e)
raise falcon.HTTPBadRequest(description=e.format_message())
try:
created_documents = db_api.documents_create(
bucket_name, documents, validation_policies)
except db_exc.DBDuplicateEntry as e:
raise falcon.HTTPConflict()
raise falcon.HTTPConflict(description=e.format_message())
except Exception as e:
raise falcon.HTTPInternalServerError()
raise falcon.HTTPInternalServerError(
description=e.format_message())
if created_documents:
resp.body = self.to_yaml_body(

View File

@ -40,8 +40,8 @@ class RevisionDocumentsResource(api_base.BaseResource):
try:
documents = db_api.revision_get_documents(
revision_id, **sanitized_params)
except errors.RevisionNotFound:
raise falcon.HTTPNotFound()
except errors.RevisionNotFound as e:
raise falcon.HTTPNotFound(description=e.format_message())
resp.status = falcon.HTTP_200
resp.append_header('Content-Type', 'application/x-yaml')

View File

@ -0,0 +1,113 @@
# Copyright 2017 AT&T Intellectual Property. All other 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.
import yaml
import falcon
from oslo_log import log as logging
from deckhand.control import base as api_base
from deckhand.control.views import revision_tag as revision_tag_view
from deckhand.db.sqlalchemy import api as db_api
from deckhand import errors
LOG = logging.getLogger(__name__)
class RevisionTagsResource(api_base.BaseResource):
"""API resource for realizing CRUD for revision tags."""
def on_post(self, req, resp, revision_id, tag=None):
"""Creates a revision tag."""
body = req.stream.read(req.content_length or 0)
try:
tag_data = yaml.safe_load(body)
except yaml.YAMLError as e:
error_msg = ("Could not parse the request body into YAML data. "
"Details: %s." % e)
LOG.error(error_msg)
raise falcon.HTTPBadRequest(description=e)
try:
resp_tag = db_api.revision_tag_create(revision_id, tag, tag_data)
except errors.RevisionNotFound as e:
raise falcon.HTTPNotFound(description=e.format_message())
except errors.RevisionTagBadFormat as e:
raise falcon.HTTPBadRequest(description=e.format_message())
resp_body = revision_tag_view.ViewBuilder().show(resp_tag)
resp.status = falcon.HTTP_201
resp.append_header('Content-Type', 'application/x-yaml')
resp.body = self.to_yaml_body(resp_body)
def on_get(self, req, resp, revision_id, tag=None):
"""Show tag details or list all tags for a revision."""
if tag:
self._show_tag(req, resp, revision_id, tag)
else:
self._list_all_tags(req, resp, revision_id)
def _show_tag(self, req, resp, revision_id, tag):
"""Retrieve details for a specified tag."""
try:
resp_tag = db_api.revision_tag_get(revision_id, tag)
except (errors.RevisionNotFound,
errors.RevisionTagNotFound) as e:
raise falcon.HTTPNotFound(description=e.format_message())
resp_body = revision_tag_view.ViewBuilder().show(resp_tag)
resp.status = falcon.HTTP_200
resp.append_header('Content-Type', 'application/x-yaml')
resp.body = self.to_yaml_body(resp_body)
def _list_all_tags(self, req, resp, revision_id):
"""List all tags for a revision."""
try:
resp_tags = db_api.revision_tag_get_all(revision_id)
except errors.RevisionNotFound as e:
raise falcon.HTTPNotFound(e.format_message())
resp_body = revision_tag_view.ViewBuilder().list(resp_tags)
resp.status = falcon.HTTP_200
resp.append_header('Content-Type', 'application/x-yaml')
resp.body = self.to_yaml_body(resp_body)
def on_delete(self, req, resp, revision_id, tag=None):
"""Deletes a single tag or deletes all tags for a revision."""
if tag:
self._delete_tag(req, resp, revision_id, tag)
else:
self._delete_all_tags(req, resp, revision_id)
def _delete_tag(self, req, resp, revision_id, tag):
"""Delete a specified tag."""
try:
db_api.revision_tag_delete(revision_id, tag)
except (errors.RevisionNotFound,
errors.RevisionTagNotFound) as e:
raise falcon.HTTPNotFound(description=e.format_message())
resp.append_header('Content-Type', 'application/x-yaml')
resp.status = falcon.HTTP_204
def _delete_all_tags(self, req, resp, revision_id):
"""Delete all tags for a revision."""
try:
db_api.revision_tag_delete_all(revision_id)
except errors.RevisionNotFound as e:
raise falcon.HTTPNotFound(description=e.format_message())
resp.append_header('Content-Type', 'application/x-yaml')
resp.status = falcon.HTTP_204

View File

@ -45,8 +45,8 @@ class RevisionsResource(api_base.BaseResource):
"""
try:
revision = db_api.revision_get(revision_id)
except errors.RevisionNotFound:
raise falcon.HTTPNotFound()
except errors.RevisionNotFound as e:
raise falcon.HTTPNotFound(description=e.format_message())
revision_resp = self.view_builder.show(revision)
resp.status = falcon.HTTP_200

View File

@ -0,0 +1,33 @@
# Copyright 2017 AT&T Intellectual Property. All other 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 deckhand.control import common
class ViewBuilder(common.ViewBuilder):
"""Model revision tag API responses as a python dictionary."""
_collection_name = 'revisions'
def list(self, tags):
return [self._show(tag) for tag in tags]
def show(self, tag):
return self._show(tag)
def _show(self, tag):
return {
'tag': tag.get('tag', None),
'data': tag.get('data', {})
}

View File

@ -17,6 +17,7 @@
import ast
import copy
import functools
import threading
from oslo_config import cfg
@ -205,7 +206,6 @@ def bucket_get_or_create(bucket_name, session=None):
####################
def revision_create(session=None):
session = session or get_session()
@ -233,10 +233,23 @@ def revision_get(revision_id, session=None):
return revision.to_dict()
def require_revision_exists(f):
"""Decorator to require the specified revision to exist.
Requires the wrapped function to use revision_id as the first argument.
"""
@functools.wraps(f)
def wrapper(revision_id, *args, **kwargs):
revision_get(revision_id)
return f(revision_id, *args, **kwargs)
return wrapper
def revision_get_all(session=None):
"""Return list of all revisions."""
session = session or get_session()
revisions = session.query(models.Revision).all()
revisions = session.query(models.Revision)\
.all()
return [r.to_dict() for r in revisions]
@ -303,3 +316,93 @@ def _filter_revision_documents(documents, **filters):
filtered_documents.append(document)
return filtered_documents
####################
@require_revision_exists
def revision_tag_create(revision_id, tag, data=None, session=None):
"""Create a revision tag.
:returns: The tag that was created if not already present in the database,
else None.
"""
session = session or get_session()
tag_model = models.RevisionTag()
try:
assert not data or isinstance(data, dict)
except AssertionError:
raise errors.RevisionTagBadFormat(data=data)
try:
with session.begin():
tag_model.update(
{'tag': tag, 'data': data, 'revision_id': revision_id})
tag_model.save(session=session)
resp = tag_model.to_dict()
except db_exception.DBDuplicateEntry:
resp = None
return resp
@require_revision_exists
def revision_tag_get(revision_id, tag, session=None):
"""Retrieve tag details.
:returns: None
:raises RevisionTagNotFound: If ``tag`` for ``revision_id`` was not found.
"""
session = session or get_session()
try:
tag = session.query(models.RevisionTag)\
.filter_by(tag=tag, revision_id=revision_id)\
.one()
except sa_orm.exc.NoResultFound:
raise errors.RevisionTagNotFound(tag=tag, revision=revision_id)
return tag.to_dict()
@require_revision_exists
def revision_tag_get_all(revision_id, session=None):
"""Return list of tags for a revision.
:returns: List of tags for ``revision_id``, ordered by the tag name by
default.
"""
session = session or get_session()
tags = session.query(models.RevisionTag)\
.filter_by(revision_id=revision_id)\
.order_by(models.RevisionTag.tag)\
.all()
return [t.to_dict() for t in tags]
@require_revision_exists
def revision_tag_delete(revision_id, tag, session=None):
"""Delete a specific tag for a revision.
:returns: None
"""
session = session or get_session()
result = session.query(models.RevisionTag)\
.filter_by(tag=tag, revision_id=revision_id)\
.delete(synchronize_session=False)
if result == 0:
raise errors.RevisionTagNotFound(tag=tag, revision=revision_id)
@require_revision_exists
def revision_tag_delete_all(revision_id, session=None):
"""Delete all tags for a revision.
:returns: None
"""
session = session or get_session()
session.query(models.RevisionTag)\
.filter_by(revision_id=revision_id)\
.delete(synchronize_session=False)

View File

@ -26,6 +26,7 @@ from sqlalchemy import Integer
from sqlalchemy.orm import relationship
from sqlalchemy import schema
from sqlalchemy import String
from sqlalchemy import Unicode
# Declarative base class which maintains a catalog of classes and tables
@ -41,10 +42,6 @@ class DeckhandBase(models.ModelBase, models.TimestampMixin):
__protected_attributes__ = set([
"created_at", "updated_at", "deleted_at", "deleted"])
def save(self, session=None):
from deckhand.db.sqlalchemy import api as db_api
super(DeckhandBase, self).save(session or db_api.get_session())
created_at = Column(DateTime, default=lambda: timeutils.utcnow(),
nullable=False)
updated_at = Column(DateTime, default=lambda: timeutils.utcnow(),
@ -52,11 +49,14 @@ class DeckhandBase(models.ModelBase, models.TimestampMixin):
deleted_at = Column(DateTime, nullable=True)
deleted = Column(Boolean, nullable=False, default=False)
def save(self, session=None):
from deckhand.db.sqlalchemy import api as db_api
super(DeckhandBase, self).save(session or db_api.get_session())
def safe_delete(self, session=None):
"""Delete this object."""
self.deleted = True
self.deleted_at = timeutils.utcnow()
self.save(session=session)
super(DeckhandBase, self).delete(session=session)
def keys(self):
return self.__dict__.keys()
@ -68,22 +68,20 @@ class DeckhandBase(models.ModelBase, models.TimestampMixin):
return self.__dict__.items()
def to_dict(self, raw_dict=False):
"""Conver the object into dictionary format.
"""Convert the object into dictionary format.
:param raw_dict: if True, returns unmodified data; else returns data
expected by users.
:param raw_dict: Renames the key "_metadata" to "metadata".
"""
d = self.__dict__.copy()
# Remove private state instance, as it is not serializable and causes
# CircularReference.
d.pop("_sa_instance_state")
if 'deleted_at' not in d:
d.setdefault('deleted_at', None)
for k in ["created_at", "updated_at", "deleted_at"]:
for k in ["created_at", "updated_at", "deleted_at", "deleted"]:
if k in d and d[k]:
d[k] = d[k].isoformat()
else:
d.setdefault(k, None)
# NOTE(fmontei): ``metadata`` is reserved by the DB, so ``_metadata``
# must be used to store document metadata information in the DB.
@ -114,15 +112,29 @@ class Revision(BASE, DeckhandBase):
default=lambda: str(uuid.uuid4()))
documents = relationship("Document")
validation_policies = relationship("ValidationPolicy")
tags = relationship("RevisionTag")
def to_dict(self):
d = super(Revision, self).to_dict()
d['documents'] = [doc.to_dict() for doc in self.documents]
d['validation_policies'] = [
vp.to_dict() for vp in self.validation_policies]
d['tags'] = [tag.to_dict() for tag in self.tags]
return d
class RevisionTag(BASE, DeckhandBase):
UNIQUE_CONSTRAINTS = ('tag', 'revision_id')
__tablename__ = 'revision_tags'
__table_args__ = (DeckhandBase.gen_unqiue_contraint(*UNIQUE_CONSTRAINTS),)
tag = Column(Unicode(80), primary_key=True, nullable=False)
data = Column(oslo_types.JsonEncodedDict(), nullable=True, default={})
revision_id = Column(
Integer, ForeignKey('revisions.id', ondelete='CASCADE'),
nullable=False)
class DocumentMixin(object):
"""Mixin class for sharing common columns across all document resources
such as documents themselves, layering policies and validation policies.

View File

@ -111,5 +111,17 @@ class DocumentNotFound(DeckhandException):
class RevisionNotFound(DeckhandException):
msg_fmt = ("The requested revision %(revision)s was not found.")
msg_fmt = "The requested revision %(revision)s was not found."
code = 404
class RevisionTagNotFound(DeckhandException):
msg_fmt = ("The requested tag %(tag)s for revision %(revision)s was not "
"found.")
code = 404
class RevisionTagBadFormat(DeckhandException):
msg_fmt = ("The requested tag data %(data)s must either be null or "
"dictionary.")
code = 400

View File

@ -0,0 +1,3 @@
---
last: good
random: data

View File

@ -0,0 +1,100 @@
# Test success paths for revision tag create, read, update and delete.
#
# 1. Purges existing data to ensure test isolation
# 2. Adds a document to a bucket to create a revision needed by these tests.
# 3. Creates a tag "foo" for the created revision.
# 4. Verifies:
# - Tag "foo" was created for the revision
# 5. Creates a tag "bar" with associated data for the same revision.
# 6. Verifies:
# - Tag "bar" was created with expected data.
# 7. Delete tag "foo" and verify that only tag "bar" is listed afterward.
# 8. Delete all tags and verify that no tags are listed.
defaults:
request_headers:
content-type: application/x-yaml
response_headers:
content-type: application/x-yaml
tests:
- name: purge
desc: Begin testing from known state.
DELETE: /api/v1.0/revisions
status: 204
# Create a revision implicitly by creating a document.
- name: initialize
desc: Create initial documents
PUT: /api/v1.0/bucket/mop/documents
status: 200
data: <@resources/design-doc-layering-sample.yaml
- name: create_tag
desc: Create a tag for the revision
POST: /api/v1.0/revisions/$RESPONSE['$.[0].status.revision']/tags/foo
status: 201
response_multidoc_jsonpaths:
$[0].data: {}
$[0].tag: foo
- name: show_tag
desc: Verify showing created tag works
GET: /api/v1.0/revisions/$HISTORY['initialize'].$RESPONSE['$.[0].status.revision']/tags/foo
status: 200
response_multidoc_jsonpaths:
$[0].data: {}
$[0].tag: foo
- name: create_tag_with_data
desc: Create a tag with data for the revision
POST: /api/v1.0/revisions/$HISTORY['initialize'].$RESPONSE['$.[0].status.revision']/tags/bar
status: 201
data: <@resources/sample-tag-data.yaml
response_multidoc_jsonpaths:
$[0].tag: bar
$[0].data.last: good
$[0].data.random: data
- name: list_tags
desc: Verify listing tags contains created tag
GET: /api/v1.0/revisions/$HISTORY['initialize'].$RESPONSE['$.[0].status.revision']/tags
status: 200
response_multidoc_jsonpaths:
$.[0].tag: bar
$.[0].data.last: good
$.[0].data.random: data
$.[1].tag: foo
$.[1].data: {}
- name: delete_tag
desc: Verify deleting tag works
DELETE: /api/v1.0/revisions/$HISTORY['initialize'].$RESPONSE['$.[0].status.revision']/tags/foo
status: 204
- name: verify_tag_delete
desc: Verify listing tags contains non-deleted tag
GET: /api/v1.0/revisions/$HISTORY['initialize'].$RESPONSE['$.[0].status.revision']/tags
status: 200
response_multidoc_jsonpaths:
$.[0].tag: bar
$.[0].data.last: good
$.[0].data.random: data
- name: delete_all_tags
desc: Verify deleting tag works
DELETE: /api/v1.0/revisions/$HISTORY['initialize'].$RESPONSE['$.[0].status.revision']/tags
status: 204
- name: verify_tag_delete_all
desc: Verify all tags have been deleted
GET: /api/v1.0/revisions/$HISTORY['initialize'].$RESPONSE['$.[0].status.revision']/tags
status: 200
response_multidoc_jsonpaths:
$: null

View File

@ -18,7 +18,6 @@ import gabbi.json_parser
import os
import yaml
TESTS_DIR = 'gabbits'
@ -40,12 +39,20 @@ class MultidocJsonpaths(gabbi.handlers.jsonhandler.JSONHandler):
@staticmethod
def loads(string):
# NOTE: The simple approach to handling dictionary versus list response
# bodies is to always parse the response body as a list and index into
# the first element using [0] throughout the tests.
return list(yaml.safe_load_all(string))
def load_tests(loader, tests, pattern):
test_dir = os.path.join(os.path.dirname(__file__), TESTS_DIR)
return gabbi.driver.build_tests(test_dir, loader,
# NOTE(fmontei): When there are multiple handlers listed that
# accept the same content-type, the one that is earliest in the
# list will be used. Thus, we cannot specify multiple content
# handlers for handling list/dictionary responses from the server
# using different handlers.
content_handlers=[MultidocJsonpaths],
verbose=True,
url=os.environ['DECKHAND_TEST_URL'])

View File

@ -34,8 +34,11 @@ class DeckhandTestCase(testtools.TestCase):
CONF.set_override(name, override, group)
self.addCleanup(CONF.clear_override, name, group)
def assertEmpty(self, list):
self.assertEqual(0, len(list))
def assertEmpty(self, collection):
if isinstance(collection, list):
self.assertEqual(0, len(collection))
elif isinstance(collection, dict):
self.assertEqual(0, len(collection.keys()))
class DeckhandWithDBTestCase(DeckhandTestCase):

View File

@ -18,6 +18,7 @@ from deckhand.control import api
from deckhand.control import base
from deckhand.control import buckets
from deckhand.control import revision_documents
from deckhand.control import revision_tags
from deckhand.control import revisions
from deckhand.control import secrets
from deckhand.tests.unit import base as test_base
@ -27,7 +28,8 @@ class TestApi(test_base.DeckhandTestCase):
def setUp(self):
super(TestApi, self).setUp()
for resource in (buckets, revision_documents, revisions, secrets):
for resource in (buckets, revision_documents, revision_tags, revisions,
secrets):
resource_name = resource.__name__.split('.')[-1]
resource_obj = mock.patch.object(
resource, '%sResource' % resource_name.title().replace(
@ -54,6 +56,10 @@ class TestApi(test_base.DeckhandTestCase):
self.revisions_resource()),
mock.call('/api/v1.0/revisions/{revision_id}/documents',
self.revision_documents_resource()),
mock.call('/api/v1.0/revisions/{revision_id}/tags',
self.revision_tags_resource()),
mock.call('/api/v1.0/revisions/{revision_id}/tags/{tag}',
self.revision_tags_resource()),
mock.call('/api/v1.0/secrets', self.secrets_resource())
])
mock_config.parse_args.assert_called_once_with()

View File

@ -21,8 +21,7 @@ from deckhand.tests.unit import base
BASE_EXPECTED_FIELDS = ("created_at", "updated_at", "deleted_at", "deleted")
DOCUMENT_EXPECTED_FIELDS = BASE_EXPECTED_FIELDS + (
"id", "schema", "name", "metadata", "data", "revision_id", "bucket_id")
REVISION_EXPECTED_FIELDS = BASE_EXPECTED_FIELDS + (
"id", "documents", "validation_policies")
REVISION_EXPECTED_FIELDS = ("id", "documents", "validation_policies", "tags")
# TODO(fmontei): Move this into a separate module called `fixtures`.
@ -81,8 +80,13 @@ class TestDbBase(base.DeckhandWithDBTestCase):
return doc
def delete_document(self, document_id):
return db_api.document_delete(document_id)
def create_revision(self):
# Implicitly creates a revision and returns it.
documents = [DocumentFixture.get_minimal_fixture()]
bucket_name = test_utils.rand_name('bucket')
revision_id = self.create_documents(bucket_name, documents)[0][
'revision_id']
return revision_id
def show_revision(self, revision_id):
revision = db_api.revision_get(revision_id)

View File

@ -0,0 +1,118 @@
# Copyright 2017 AT&T Intellectual Property. All other 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 deckhand.db.sqlalchemy import api as db_api
from deckhand import errors
from deckhand.tests import test_utils
from deckhand.tests.unit.db import base
class TestRevisionTags(base.TestDbBase):
def setUp(self):
super(TestRevisionTags, self).setUp()
self.revision_id = self.create_revision()
def test_list_tags(self):
retrieved_tags = db_api.revision_tag_get_all(self.revision_id)
self.assertEmpty(retrieved_tags)
def test_create_show_and_list_many_tags_without_data(self):
expected_tag_names = []
for _ in range(4):
tag = test_utils.rand_name(self.__class__.__name__ + '-Tag')
db_api.revision_tag_create(self.revision_id, tag)
expected_tag_names.append(tag)
retrieved_tags = db_api.revision_tag_get_all(self.revision_id)
retrieved_tag_names = [t['tag'] for t in retrieved_tags]
self.assertEqual(4, len(retrieved_tags))
self.assertEqual(sorted(expected_tag_names), retrieved_tag_names)
for tag in expected_tag_names:
# Should not raise an exception.
resp = db_api.revision_tag_get(self.revision_id, tag)
self.assertIn('tag', resp)
self.assertIn('data', resp)
def test_create_show_and_list_many_tags_with_data(self):
expected_tags = []
for _ in range(4):
rand_prefix = test_utils.rand_name(self.__class__.__name__)
tag = rand_prefix + '-Tag'
data_key = rand_prefix + '-Key'
data_val = rand_prefix + '-Val'
db_api.revision_tag_create(
self.revision_id, tag, {data_key: data_val})
expected_tags.append({'tag': tag, 'data': {data_key: data_val}})
expected_tags = sorted(expected_tags, key=lambda t: t['tag'])
retrieved_tags = db_api.revision_tag_get_all(self.revision_id)
self.assertEqual(4, len(retrieved_tags))
retrieved_tags = [
{k: t[k] for k in t.keys() if k in ('data', 'tag')}
for t in retrieved_tags]
self.assertEqual(sorted(expected_tags, key=lambda t: t['tag']),
retrieved_tags)
def test_create_and_delete_tags(self):
tags = []
for _ in range(4):
tag = test_utils.rand_name(self.__class__.__name__ + '-Tag')
db_api.revision_tag_create(self.revision_id, tag)
tags.append(tag)
for idx, tag in enumerate(tags):
expected_tag_names = sorted(tags[idx + 1:])
result = db_api.revision_tag_delete(self.revision_id, tag)
self.assertIsNone(result)
retrieved_tags = db_api.revision_tag_get_all(self.revision_id)
retrieved_tag_names = [t['tag'] for t in retrieved_tags]
self.assertEqual(expected_tag_names, retrieved_tag_names)
self.assertRaises(
errors.RevisionTagNotFound, db_api.revision_tag_get,
self.revision_id, tag)
def test_delete_all_tags(self):
for _ in range(4):
tag = test_utils.rand_name(self.__class__.__name__ + '-Tag')
db_api.revision_tag_create(self.revision_id, tag)
result = db_api.revision_tag_delete_all(self.revision_id)
self.assertIsNone(result)
retrieved_tags = db_api.revision_tag_get_all(self.revision_id)
self.assertEmpty(retrieved_tags)
def test_delete_all_tags_without_any_tags(self):
# Validate that no tags exist to begin with.
retrieved_tags = db_api.revision_tag_get_all(self.revision_id)
self.assertEmpty(retrieved_tags)
# Validate that deleting all tags without any tags doesn't raise
# errors.
db_api.revision_tag_delete_all(self.revision_id)
def test_create_duplicate_tag(self):
tag = test_utils.rand_name(self.__class__.__name__ + '-Tag')
# Create the same tag twice and validate that it returns None the
# second time.
db_api.revision_tag_create(self.revision_id, tag)
resp = db_api.revision_tag_create(self.revision_id, tag)
self.assertIsNone(resp)

View File

@ -0,0 +1,46 @@
# Copyright 2017 AT&T Intellectual Property. All other 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 deckhand.db.sqlalchemy import api as db_api
from deckhand import errors
from deckhand.tests import test_utils
from deckhand.tests.unit.db import base
class TestRevisionTagsNegative(base.TestDbBase):
def test_create_tag_revision_not_found(self):
self.assertRaises(
errors.RevisionNotFound, db_api.revision_tag_create,
test_utils.rand_uuid_hex())
def test_show_tag_revision_not_found(self):
self.assertRaises(
errors.RevisionNotFound, db_api.revision_tag_get,
test_utils.rand_uuid_hex())
def test_delete_tag_revision_not_found(self):
self.assertRaises(
errors.RevisionNotFound, db_api.revision_tag_delete,
test_utils.rand_uuid_hex())
def test_list_tags_revision_not_found(self):
self.assertRaises(
errors.RevisionNotFound, db_api.revision_tag_get_all,
test_utils.rand_uuid_hex())
def test_delete_all_tags_revision_not_found(self):
self.assertRaises(
errors.RevisionNotFound, db_api.revision_tag_delete_all,
test_utils.rand_uuid_hex())

View File

@ -0,0 +1,60 @@
# Copyright 2017 AT&T Intellectual Property. All other 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 deckhand.control.views import revision_tag
from deckhand.db.sqlalchemy import api as db_api
from deckhand.tests import test_utils
from deckhand.tests.unit.db import base
class TestRevisionViews(base.TestDbBase):
def setUp(self):
super(TestRevisionViews, self).setUp()
self.view_builder = revision_tag.ViewBuilder()
self.revision_id = self.create_revision()
def test_revision_tag_show_view(self):
rand_prefix = test_utils.rand_name(self.__class__.__name__)
tag = rand_prefix + '-Tag'
data_key = rand_prefix + '-Key'
data_val = rand_prefix + '-Val'
expected_view = {'tag': tag, 'data': {data_key: data_val}}
created_tag = db_api.revision_tag_create(
self.revision_id, tag, {data_key: data_val})
actual_view = self.view_builder.show(created_tag)
self.assertEqual(expected_view, actual_view)
def test_revision_tag_list_view(self):
expected_view = []
# Create 2 revision tags for the same revision.
for _ in range(2):
rand_prefix = test_utils.rand_name(self.__class__.__name__)
tag = rand_prefix + '-Tag'
data_key = rand_prefix + '-Key'
data_val = rand_prefix + '-Val'
db_api.revision_tag_create(
self.revision_id, tag, {data_key: data_val})
expected_view.append({'tag': tag, 'data': {data_key: data_val}})
retrieved_tags = db_api.revision_tag_get_all(self.revision_id)
actual_view = self.view_builder.list(retrieved_tags)
self.assertEqual(sorted(expected_view, key=lambda t: t['tag']),
actual_view)