Only allow one LayeringPolicy to exist in the system.

This PS enforces the design requirement that says that only 1
layering policy can exist in the system at once. Attempting to
create another layering policy with a different name is a 409 error.
The existing layering policy can be updated by passing in a document
with the same `metadata.name` and `schema` as the existing one.

Closes-Bug: https://github.com/att-comdev/deckhand/issues/12
Change-Id: I7cad2d600c931c8701c3faaf2967be782984528b
This commit is contained in:
Felipe Monteiro 2017-10-19 21:26:07 +01:00
parent 2ba761a36c
commit 55b13dc4eb
9 changed files with 174 additions and 21 deletions

View File

@ -90,7 +90,8 @@ class BucketsResource(api_base.BaseResource):
try: try:
created_documents = db_api.documents_create( created_documents = db_api.documents_create(
bucket_name, documents, validations=validations) bucket_name, documents, validations=validations)
except deckhand_errors.DocumentExists as e: except (deckhand_errors.DocumentExists,
deckhand_errors.SingletonDocumentConflict) as e:
raise falcon.HTTPConflict(description=e.format_message()) raise falcon.HTTPConflict(description=e.format_message())
except Exception as e: except Exception as e:
raise falcon.HTTPInternalServerError(description=six.text_type(e)) raise falcon.HTTPInternalServerError(description=six.text_type(e))

View File

@ -56,13 +56,17 @@ class ValidationsResource(api_base.BaseResource):
def on_get(self, req, resp, revision_id, validation_name=None, def on_get(self, req, resp, revision_id, validation_name=None,
entry_id=None): entry_id=None):
if all([validation_name, entry_id]): if all([validation_name, entry_id]):
self._show_validation_entry( resp_body = self._show_validation_entry(
req, resp, revision_id, validation_name, entry_id) req, resp, revision_id, validation_name, entry_id)
elif validation_name: elif validation_name:
self._list_validation_entries(req, resp, revision_id, resp_body = self._list_validation_entries(req, resp, revision_id,
validation_name) validation_name)
else: else:
self._list_all_validations(req, resp, revision_id) resp_body = self._list_all_validations(req, resp, revision_id)
resp.status = falcon.HTTP_200
resp.append_header('Content-Type', 'application/x-yaml')
resp.body = resp_body
@policy.authorize('deckhand:show_validation') @policy.authorize('deckhand:show_validation')
def _show_validation_entry(self, req, resp, revision_id, validation_name, def _show_validation_entry(self, req, resp, revision_id, validation_name,
@ -80,9 +84,7 @@ class ValidationsResource(api_base.BaseResource):
raise falcon.HTTPNotFound(description=e.format_message()) raise falcon.HTTPNotFound(description=e.format_message())
resp_body = self.view_builder.show_entry(entry) resp_body = self.view_builder.show_entry(entry)
resp.status = falcon.HTTP_200 return resp_body
resp.append_header('Content-Type', 'application/x-yaml')
resp.body = resp_body
@policy.authorize('deckhand:list_validations') @policy.authorize('deckhand:list_validations')
def _list_validation_entries(self, req, resp, revision_id, def _list_validation_entries(self, req, resp, revision_id,
@ -94,9 +96,7 @@ class ValidationsResource(api_base.BaseResource):
raise falcon.HTTPNotFound(description=e.format_message()) raise falcon.HTTPNotFound(description=e.format_message())
resp_body = self.view_builder.list_entries(entries) resp_body = self.view_builder.list_entries(entries)
resp.status = falcon.HTTP_200 return resp_body
resp.append_header('Content-Type', 'application/x-yaml')
resp.body = resp_body
@policy.authorize('deckhand:list_validations') @policy.authorize('deckhand:list_validations')
def _list_all_validations(self, req, resp, revision_id): def _list_all_validations(self, req, resp, revision_id):
@ -104,8 +104,6 @@ class ValidationsResource(api_base.BaseResource):
validations = db_api.validation_get_all(revision_id) validations = db_api.validation_get_all(revision_id)
except errors.RevisionNotFound as e: except errors.RevisionNotFound as e:
raise falcon.HTTPNotFound(description=e.format_message()) raise falcon.HTTPNotFound(description=e.format_message())
resp_body = self.view_builder.list(validations)
resp.status = falcon.HTTP_200 resp_body = self.view_builder.list(validations)
resp.append_header('Content-Type', 'application/x-yaml') return resp_body
resp.body = resp_body

View File

@ -98,6 +98,53 @@ def raw_query(query, **kwargs):
return get_engine().execute(stmt) return get_engine().execute(stmt)
def require_unique_document_schema(schema=None):
"""Decorator to enforce only one singleton document exists in the system.
An example of a singleton document is a ``LayeringPolicy`` document.
Only one singleton document can exist within the system at any time. It is
an error to attempt to insert a new document with the same ``schema`` if it
has a different ``metadata.name`` than the existing document.
A singleton document that already exists can be updated, if the document
that is passed in has the same name/schema as the existing one.
The existing singleton document can be replaced by first deleting it
and only then creating a new one.
:raises SingletonDocumentConflict: if a singleton document in the system
already exists and any of the documents to be created has the same
``schema`` but has a ``metadata.name`` that differs from the one
already registered.
"""
def decorator(f):
if schema not in types.DOCUMENT_SCHEMA_TYPES:
raise errors.DeckhandException(
'Unrecognized document schema %s.' % schema)
@functools.wraps(f)
def wrapper(bucket_name, documents, *args, **kwargs):
existing_documents = revision_get_documents(
schema=schema, deleted=False, include_history=False)
existing_document_names = [x['name'] for x in existing_documents]
# `conflict_names` is calculated by checking whether any documents
# in `documents` is a layering policy with a name not found in
# `existing_documents`.
conflicting_names = [
x['metadata']['name'] for x in documents
if x['metadata']['name'] not in existing_document_names and
x['schema'].startswith(schema)]
if existing_document_names and conflicting_names:
raise errors.SingletonDocumentConflict(
document=existing_document_names[0],
conflict=conflicting_names)
return f(bucket_name, documents, *args, **kwargs)
return wrapper
return decorator
@require_unique_document_schema(types.LAYERING_POLICY_SCHEMA)
def documents_create(bucket_name, documents, validations=None, def documents_create(bucket_name, documents, validations=None,
session=None): session=None):
"""Create a set of documents and associated bucket. """Create a set of documents and associated bucket.
@ -203,7 +250,8 @@ def _documents_create(bucket_name, values_list, session=None):
try: try:
existing_document = document_get( existing_document = document_get(
raw_dict=True, **{x: values[x] for x in filters}) raw_dict=True, deleted=False,
**{x: values[x] for x in filters})
except errors.DocumentNotFound: except errors.DocumentNotFound:
# Ignore bad data at this point. Allow creation to bubble up the # Ignore bad data at this point. Allow creation to bubble up the
# error related to bad data. # error related to bad data.
@ -214,7 +262,8 @@ def _documents_create(bucket_name, values_list, session=None):
# Ignore redundant validation policies as they are allowed to exist # Ignore redundant validation policies as they are allowed to exist
# in multiple buckets. # in multiple buckets.
if (existing_document['bucket_name'] != bucket_name and if (existing_document['bucket_name'] != bucket_name and
existing_document['schema'] != types.VALIDATION_POLICY_SCHEMA): not existing_document['schema'].startswith(
types.VALIDATION_POLICY_SCHEMA)):
raise errors.DocumentExists( raise errors.DocumentExists(
schema=existing_document['schema'], schema=existing_document['schema'],
name=existing_document['name'], name=existing_document['name'],
@ -644,7 +693,7 @@ def revision_get_documents(revision_id=None, include_history=True,
.filter_by(id=revision_id)\ .filter_by(id=revision_id)\
.one() .one()
else: else:
# If no revision_id is specified, grab the newest one. # If no revision_id is specified, grab the latest one.
revision = session.query(models.Revision)\ revision = session.query(models.Revision)\
.order_by(models.Revision.created_at.desc())\ .order_by(models.Revision.created_at.desc())\
.first() .first()

View File

@ -188,6 +188,14 @@ class DocumentExists(DeckhandException):
code = 409 code = 409
class SingletonDocumentConflict(DeckhandException):
msg_fmt = ("A singleton document by the name %(document)s already "
"exists in the system. The new document %(conflict)s cannot be "
"created. To create a document with a new name, delete the "
"current one first.")
code = 409
class LayeringPolicyNotFound(DeckhandException): class LayeringPolicyNotFound(DeckhandException):
msg_fmt = ("LayeringPolicy with schema %(schema)s not found in the " msg_fmt = ("LayeringPolicy with schema %(schema)s not found in the "
"system.") "system.")

View File

@ -93,7 +93,7 @@ class DocumentFactory(DeckhandFactory):
"layerOrder": [] "layerOrder": []
}, },
"metadata": { "metadata": {
"name": "layering-policy", "name": "placeholder",
"schema": "metadata/Control/v%s" % DeckhandFactory.API_VERSION "schema": "metadata/Control/v%s" % DeckhandFactory.API_VERSION
}, },
"schema": "deckhand/LayeringPolicy/v%s" % DeckhandFactory.API_VERSION "schema": "deckhand/LayeringPolicy/v%s" % DeckhandFactory.API_VERSION
@ -157,7 +157,10 @@ class DocumentFactory(DeckhandFactory):
layer_order = ["global", "region", "site"] layer_order = ["global", "region", "site"]
else: else:
raise ValueError("'num_layers' must be a value between 1 - 3.") raise ValueError("'num_layers' must be a value between 1 - 3.")
self.LAYERING_DEFINITION['data']['layerOrder'] = layer_order self.layering_definition = copy.deepcopy(self.LAYERING_DEFINITION)
self.layering_definition['metadata']['name'] = test_utils.rand_name(
'layering-policy')
self.layering_definition['data']['layerOrder'] = layer_order
if not isinstance(docs_per_layer, (list, tuple)): if not isinstance(docs_per_layer, (list, tuple)):
raise TypeError("'docs_per_layer' must be a list or tuple " raise TypeError("'docs_per_layer' must be a list or tuple "
@ -223,7 +226,7 @@ class DocumentFactory(DeckhandFactory):
:type site_parent_selectors: list :type site_parent_selectors: list
:returns: Rendered template of the form specified above. :returns: Rendered template of the form specified above.
""" """
rendered_template = [self.LAYERING_DEFINITION] rendered_template = [self.layering_definition]
layer_order = rendered_template[0]['data']['layerOrder'] layer_order = rendered_template[0]['data']['layerOrder']
for layer_idx in range(self.num_layers): for layer_idx in range(self.num_layers):

View File

@ -19,6 +19,7 @@ from oslo_config import cfg
from deckhand.control import buckets from deckhand.control import buckets
from deckhand import factories from deckhand import factories
from deckhand.tests import test_utils
from deckhand.tests.unit.control import base as test_base from deckhand.tests.unit.control import base as test_base
CONF = cfg.CONF CONF = cfg.CONF
@ -134,6 +135,32 @@ schema:
self.assertEqual(400, resp.status_code) self.assertEqual(400, resp.status_code)
self.assertRegexpMatches(resp.text, error_re[idx]) self.assertRegexpMatches(resp.text, error_re[idx])
def test_put_conflicting_layering_policy(self):
rules = {'deckhand:create_cleartext_documents': '@'}
self.policy.set_rules(rules)
payload = factories.DocumentFactory(1, [1]).gen_test({})[0]
# Create the first layering policy.
resp = self.app.simulate_put(
'/api/v1.0/bucket/mop/documents',
headers={'Content-Type': 'application/x-yaml'},
body=yaml.safe_dump_all([payload]))
self.assertEqual(200, resp.status_code)
# Validate that a layering policy with a different, conflicting name
# raises the expected exception.
error_re = ('.*A singleton document by the name %s already exists in '
'the system.' % payload['metadata']['name'])
payload['metadata']['name'] = test_utils.rand_name('layering-policy')
resp = self.app.simulate_put(
'/api/v1.0/bucket/mop/documents',
headers={'Content-Type': 'application/x-yaml'},
body=yaml.safe_dump_all([payload]))
self.assertEqual(409, resp.status_code)
resp_error = ' '.join(resp.text.split())
self.assertRegexpMatches(resp_error, error_re)
class TestBucketsControllerNegativeRBAC(test_base.BaseControllerTest): class TestBucketsControllerNegativeRBAC(test_base.BaseControllerTest):
"""Test suite for validating negative RBAC scenarios for bucket """Test suite for validating negative RBAC scenarios for bucket

View File

@ -0,0 +1,61 @@
# 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 import errors
from deckhand import factories
from deckhand.tests import test_utils
from deckhand.tests.unit.db import base
class LayeringPoliciesBaseTest(base.TestDbBase):
def setUp(self):
super(LayeringPoliciesBaseTest, self).setUp()
# Will create 3 documents: layering policy, plus a global and site
# document.
self.documents_factory = factories.DocumentFactory(2, [1, 1])
self.document_mapping = {
"_GLOBAL_DATA_1_": {"data": {"a": {"x": 1, "y": 2}}},
"_SITE_DATA_1_": {"data": {"a": {"x": 7, "z": 3}, "b": 4}},
"_SITE_ACTIONS_1_": {
"actions": [{"method": "merge", "path": "."}]}
}
self.bucket_name = test_utils.rand_name('bucket')
def _create_layering_policy(self):
payload = self.documents_factory.gen_test(self.document_mapping)
self.create_documents(self.bucket_name, payload)
return payload[0]
class TestLayeringPolicies(LayeringPoliciesBaseTest):
def test_update_layering_policy(self):
layering_policy = self._create_layering_policy()
layering_policy['data'] = {'data': {'layerOrder': ['region', 'site']}}
self.create_documents(self.bucket_name, [layering_policy])
def test_create_new_layering_policy_after_first_deleted(self):
self._create_layering_policy()
self.create_documents(self.bucket_name, [])
self._create_layering_policy()
class TestLayeringPoliciesNegative(LayeringPoliciesBaseTest):
def test_create_conflicting_layering_policy_fails(self):
layering_policy = self._create_layering_policy()
layering_policy['metadata']['name'] = 'another-layering-policy'
self.assertRaises(errors.SingletonDocumentConflict,
self._create_layering_policy)

View File

@ -12,7 +12,6 @@
# See the License for the specific language governing permissions and # See the License for the specific language governing permissions and
# limitations under the License. # limitations under the License.
# TODO(fmontei): Make all these version-less.
DOCUMENT_SCHEMA_TYPES = ( DOCUMENT_SCHEMA_TYPES = (
CERTIFICATE_SCHEMA, CERTIFICATE_SCHEMA,
CERTIFICATE_KEY_SCHEMA, CERTIFICATE_KEY_SCHEMA,

View File

@ -0,0 +1,7 @@
---
fixes:
- |
Deckhand will allow only one document with the schema ``LayeringPolicy``
to exist in the system at a time. To update the existing layering policy,
the layerign policy with the same name as the existing one should be
passed in. To create a new layering policy, delete the existing one first.