Validate additional 'metadata.replacement' scenarios
This patch set adds additional documentation and unit tests to validate further replacement scenarios. In particular this commit adds an additional document check that looks for documents exisitng in different layers that contain the same name and same schema without any of them having `replacement: true` Change-Id: I7c033d32a6755f36e609789a748cbc6d4af06bc2
This commit is contained in:
parent
88fe773cd7
commit
60e82b7bd6
@ -75,8 +75,50 @@ def check_only_one_level_of_replacement(src_ref):
|
|||||||
# If the document has a replacement, use the replacement as the
|
# If the document has a replacement, use the replacement as the
|
||||||
# substitution source instead.
|
# substitution source instead.
|
||||||
if src_ref.is_replacement:
|
if src_ref.is_replacement:
|
||||||
error_message = ('A replacement document cannot itself'
|
error_message = ('A replacement document cannot itself be replaced by '
|
||||||
' be replaced by another document.')
|
'another document.')
|
||||||
raise errors.InvalidDocumentReplacement(
|
raise errors.InvalidDocumentReplacement(
|
||||||
schema=src_ref.schema, name=src_ref.name,
|
schema=src_ref.schema, name=src_ref.name,
|
||||||
layer=src_ref.layer, reason=error_message)
|
layer=src_ref.layer, reason=error_message)
|
||||||
|
|
||||||
|
|
||||||
|
def check_replacement_is_false_uniqueness(
|
||||||
|
document, non_replacement_documents):
|
||||||
|
"""Validate uniqueness of ``replacement: false`` for each document
|
||||||
|
identifier.
|
||||||
|
|
||||||
|
This check essentially validates that each raw document (which is uniquely
|
||||||
|
defined by (name, schema, layer)) maps to a unique rendered document
|
||||||
|
(which is uniquely defined by (name, schema)). This can be done by ensuring
|
||||||
|
that each (name, schema) pair only has one occurrence of
|
||||||
|
``replacement: false``.
|
||||||
|
|
||||||
|
Normally, a ``replacement: true`` document nukes the ``replacement: false``
|
||||||
|
parent. But when > 1 ``replacement: false`` documents with same (name,
|
||||||
|
schema) exist, the raw document unique constraint predominates over the
|
||||||
|
rendered document unique constraint, resulting in a breakdown in the
|
||||||
|
rendering process, as confusion occurs over which document to reference
|
||||||
|
for substitution data and the like.
|
||||||
|
|
||||||
|
:param document: current document in the collection that is being processed
|
||||||
|
:param non_replacement_documents: a set containing tuples of the names and
|
||||||
|
schemas of all the non-replacement documents
|
||||||
|
"""
|
||||||
|
if not document.is_control:
|
||||||
|
document_identifier = (
|
||||||
|
document['metadata']['name'],
|
||||||
|
document['metadata']['schema']
|
||||||
|
)
|
||||||
|
if document_identifier in non_replacement_documents:
|
||||||
|
error_message = (
|
||||||
|
'Documents with the same name and schema existing in '
|
||||||
|
'different layers without any of them having '
|
||||||
|
'`replacement = true` cannot exist as Deckhand will '
|
||||||
|
'arbitrarily select any of them for processing and none are '
|
||||||
|
'distinguishable from one another because none are a '
|
||||||
|
'parent or child or a replacement document.')
|
||||||
|
raise errors.InvalidDocumentReplacement(
|
||||||
|
schema=document.schema, name=document.name,
|
||||||
|
layer=document.layer, reason=error_message)
|
||||||
|
else:
|
||||||
|
non_replacement_documents.add(document_identifier)
|
||||||
|
@ -60,6 +60,10 @@ class DocumentLayering(object):
|
|||||||
def _calc_replacements_and_substitutions(
|
def _calc_replacements_and_substitutions(
|
||||||
self, substitution_sources):
|
self, substitution_sources):
|
||||||
|
|
||||||
|
# Used to track document names and schemas for documents that are not
|
||||||
|
# replacement documents
|
||||||
|
non_replacement_documents = set()
|
||||||
|
|
||||||
for document in self._documents_by_index.values():
|
for document in self._documents_by_index.values():
|
||||||
parent_meta = self._parents.get(document.meta)
|
parent_meta = self._parents.get(document.meta)
|
||||||
parent = self._documents_by_index.get(parent_meta)
|
parent = self._documents_by_index.get(parent_meta)
|
||||||
@ -71,8 +75,13 @@ class DocumentLayering(object):
|
|||||||
parent, document)
|
parent, document)
|
||||||
parent.replaced_by = document
|
parent.replaced_by = document
|
||||||
else:
|
else:
|
||||||
|
# Handles case where parent and child have replacement: false
|
||||||
|
# as in this case both documents should not be replacement
|
||||||
|
# documents, requiring them to have different schema/name pair.
|
||||||
replacement.check_child_and_parent_different_metadata_name(
|
replacement.check_child_and_parent_different_metadata_name(
|
||||||
parent, document)
|
parent, document)
|
||||||
|
replacement.check_replacement_is_false_uniqueness(
|
||||||
|
document, non_replacement_documents)
|
||||||
|
|
||||||
# Since a substitution source only provides the document's
|
# Since a substitution source only provides the document's
|
||||||
# `metadata.name` and `schema`, their tuple acts as the dictionary key.
|
# `metadata.name` and `schema`, their tuple acts as the dictionary key.
|
||||||
|
@ -185,14 +185,14 @@ class TestValidationMessageFormatting(test_base.BaseControllerTest):
|
|||||||
headers={'Content-Type': 'application/x-yaml'},
|
headers={'Content-Type': 'application/x-yaml'},
|
||||||
body=payload)
|
body=payload)
|
||||||
|
|
||||||
with mock.patch('deckhand.control.revision_documents.db_api'
|
with mock.patch('deckhand.control.revision_documents.common'
|
||||||
'.revision_documents_get', autospec=True) \
|
'.get_rendered_docs', autospec=True) \
|
||||||
as mock_get_rev_documents:
|
as mock_get_rendered_docs:
|
||||||
invalid_document = document_wrapper.DocumentDict(
|
invalid_document = document_wrapper.DocumentDict(
|
||||||
yaml.safe_load(payload))
|
yaml.safe_load(payload))
|
||||||
invalid_document.pop('metadata')
|
invalid_document.pop('metadata')
|
||||||
|
mock_get_rendered_docs.return_value = ([invalid_document], False)
|
||||||
|
|
||||||
mock_get_rev_documents.return_value = [invalid_document]
|
|
||||||
resp = self.app.simulate_get(
|
resp = self.app.simulate_get(
|
||||||
'/api/v1.0/revisions/1/rendered-documents',
|
'/api/v1.0/revisions/1/rendered-documents',
|
||||||
headers={'Content-Type': 'application/x-yaml'})
|
headers={'Content-Type': 'application/x-yaml'})
|
||||||
|
@ -12,12 +12,89 @@
|
|||||||
# 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.
|
||||||
|
|
||||||
|
import inspect
|
||||||
import itertools
|
import itertools
|
||||||
import os
|
import os
|
||||||
import yaml
|
import yaml
|
||||||
|
|
||||||
from deckhand.tests.unit.engine import test_document_layering
|
from deckhand.tests.unit.engine import test_document_layering
|
||||||
|
|
||||||
|
REPLACEMENT_3_TIER_SAMPLE = list(yaml.safe_load_all(inspect.cleandoc(
|
||||||
|
"""
|
||||||
|
---
|
||||||
|
schema: deckhand/LayeringPolicy/v1
|
||||||
|
metadata:
|
||||||
|
schema: metadata/Control/v1
|
||||||
|
name: layering-policy
|
||||||
|
storagePolicy: cleartext
|
||||||
|
data:
|
||||||
|
layerOrder:
|
||||||
|
- global
|
||||||
|
- region
|
||||||
|
- site
|
||||||
|
---
|
||||||
|
schema: armada/Chart/v1
|
||||||
|
metadata:
|
||||||
|
schema: metadata/Document/v1
|
||||||
|
name: nova-global
|
||||||
|
storagePolicy: cleartext
|
||||||
|
labels:
|
||||||
|
name: nova-global
|
||||||
|
component: nova
|
||||||
|
layeringDefinition:
|
||||||
|
abstract: false
|
||||||
|
layer: global
|
||||||
|
data:
|
||||||
|
values:
|
||||||
|
pod:
|
||||||
|
replicas:
|
||||||
|
server: 16
|
||||||
|
---
|
||||||
|
schema: armada/Chart/v1
|
||||||
|
metadata:
|
||||||
|
schema: metadata/Document/v1
|
||||||
|
name: nova
|
||||||
|
storagePolicy: cleartext
|
||||||
|
labels:
|
||||||
|
name: nova-5ec
|
||||||
|
component: nova
|
||||||
|
layeringDefinition:
|
||||||
|
abstract: false
|
||||||
|
layer: region
|
||||||
|
parentSelector:
|
||||||
|
name: nova-global
|
||||||
|
actions:
|
||||||
|
- method: merge
|
||||||
|
path: .
|
||||||
|
data: {}
|
||||||
|
---
|
||||||
|
schema: armada/Chart/v1
|
||||||
|
metadata:
|
||||||
|
schema: metadata/Document/v1
|
||||||
|
replacement: true
|
||||||
|
storagePolicy: cleartext
|
||||||
|
name: nova
|
||||||
|
layeringDefinition:
|
||||||
|
abstract: false
|
||||||
|
layer: site
|
||||||
|
parentSelector:
|
||||||
|
name: nova-5ec
|
||||||
|
actions:
|
||||||
|
- method: merge
|
||||||
|
path: .
|
||||||
|
data:
|
||||||
|
values:
|
||||||
|
pod:
|
||||||
|
replicas:
|
||||||
|
api_metadata: 16
|
||||||
|
placement: 2
|
||||||
|
osapi: 16
|
||||||
|
conductor: 16
|
||||||
|
consoleauth: 2
|
||||||
|
scheduler: 2
|
||||||
|
novncproxy: 2
|
||||||
|
""")))
|
||||||
|
|
||||||
|
|
||||||
class TestDocumentLayeringWithReplacement(
|
class TestDocumentLayeringWithReplacement(
|
||||||
test_document_layering.TestDocumentLayering):
|
test_document_layering.TestDocumentLayering):
|
||||||
@ -266,82 +343,8 @@ data:
|
|||||||
|
|
||||||
* Global document called nova-global
|
* Global document called nova-global
|
||||||
* Region document called nova (layers with nova-global)
|
* Region document called nova (layers with nova-global)
|
||||||
* Site document (replaces nova)
|
* Site document (replaces region document)
|
||||||
"""
|
"""
|
||||||
self.documents = list(yaml.safe_load_all("""
|
|
||||||
---
|
|
||||||
schema: deckhand/LayeringPolicy/v1
|
|
||||||
metadata:
|
|
||||||
schema: metadata/Control/v1
|
|
||||||
name: layering-policy
|
|
||||||
storagePolicy: cleartext
|
|
||||||
data:
|
|
||||||
layerOrder:
|
|
||||||
- global
|
|
||||||
- region
|
|
||||||
- site
|
|
||||||
---
|
|
||||||
schema: armada/Chart/v1
|
|
||||||
metadata:
|
|
||||||
schema: metadata/Document/v1
|
|
||||||
name: nova-global
|
|
||||||
storagePolicy: cleartext
|
|
||||||
labels:
|
|
||||||
name: nova-global
|
|
||||||
component: nova
|
|
||||||
layeringDefinition:
|
|
||||||
abstract: false
|
|
||||||
layer: global
|
|
||||||
data:
|
|
||||||
values:
|
|
||||||
pod:
|
|
||||||
replicas:
|
|
||||||
server: 16
|
|
||||||
---
|
|
||||||
schema: armada/Chart/v1
|
|
||||||
metadata:
|
|
||||||
schema: metadata/Document/v1
|
|
||||||
name: nova
|
|
||||||
storagePolicy: cleartext
|
|
||||||
labels:
|
|
||||||
name: nova-5ec
|
|
||||||
component: nova
|
|
||||||
layeringDefinition:
|
|
||||||
abstract: false
|
|
||||||
layer: region
|
|
||||||
parentSelector:
|
|
||||||
name: nova-global
|
|
||||||
actions:
|
|
||||||
- method: merge
|
|
||||||
path: .
|
|
||||||
data: {}
|
|
||||||
---
|
|
||||||
schema: armada/Chart/v1
|
|
||||||
metadata:
|
|
||||||
schema: metadata/Document/v1
|
|
||||||
replacement: true
|
|
||||||
storagePolicy: cleartext
|
|
||||||
name: nova
|
|
||||||
layeringDefinition:
|
|
||||||
abstract: false
|
|
||||||
layer: site
|
|
||||||
parentSelector:
|
|
||||||
name: nova-5ec
|
|
||||||
actions:
|
|
||||||
- method: merge
|
|
||||||
path: .
|
|
||||||
data:
|
|
||||||
values:
|
|
||||||
pod:
|
|
||||||
replicas:
|
|
||||||
api_metadata: 16
|
|
||||||
placement: 2
|
|
||||||
osapi: 16
|
|
||||||
conductor: 16
|
|
||||||
consoleauth: 2
|
|
||||||
scheduler: 2
|
|
||||||
novncproxy: 2
|
|
||||||
"""))
|
|
||||||
|
|
||||||
site_expected = [
|
site_expected = [
|
||||||
{
|
{
|
||||||
@ -372,7 +375,56 @@ data:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
self._test_layering(self.documents,
|
self._test_layering(REPLACEMENT_3_TIER_SAMPLE,
|
||||||
site_expected=site_expected,
|
site_expected=site_expected,
|
||||||
region_expected=None,
|
region_expected=None,
|
||||||
global_expected=global_expected)
|
global_expected=global_expected)
|
||||||
|
|
||||||
|
def test_multi_layer_replacement_with_intermediate_replacement(self):
|
||||||
|
"""Validate the following scenario:
|
||||||
|
|
||||||
|
* Global document called nova-replace
|
||||||
|
* Region document called nova-replace (layers with nova-replace and
|
||||||
|
replaces it)
|
||||||
|
* Site document (layers with region document)
|
||||||
|
"""
|
||||||
|
|
||||||
|
replacement_sample = list(REPLACEMENT_3_TIER_SAMPLE)
|
||||||
|
replacement_sample[1]['metadata']['name'] = 'nova-replace'
|
||||||
|
replacement_sample[2]['metadata']['name'] = 'nova-replace'
|
||||||
|
replacement_sample[2]['metadata']['replacement'] = True
|
||||||
|
replacement_sample[3]['metadata']['replacement'] = False
|
||||||
|
|
||||||
|
site_expected = [
|
||||||
|
{
|
||||||
|
"values": {
|
||||||
|
"pod": {
|
||||||
|
"replicas": {
|
||||||
|
"api_metadata": 16,
|
||||||
|
"placement": 2,
|
||||||
|
"osapi": 16,
|
||||||
|
"conductor": 16,
|
||||||
|
"consoleauth": 2,
|
||||||
|
"scheduler": 2,
|
||||||
|
"novncproxy": 2,
|
||||||
|
"server": 16
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
region_expected = [
|
||||||
|
{
|
||||||
|
"values": {
|
||||||
|
"pod": {
|
||||||
|
"replicas": {
|
||||||
|
"server": 16
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
self._test_layering(replacement_sample,
|
||||||
|
site_expected=site_expected,
|
||||||
|
region_expected=region_expected,
|
||||||
|
global_expected=None)
|
||||||
|
@ -24,6 +24,10 @@ class TestDocumentLayeringReplacementNegative(
|
|||||||
"""Validate that attempting to replace a child with its parent when
|
"""Validate that attempting to replace a child with its parent when
|
||||||
they don't have the same ``metadata.name`` and ``schema`` results in
|
they don't have the same ``metadata.name`` and ``schema`` results in
|
||||||
exception.
|
exception.
|
||||||
|
|
||||||
|
global
|
||||||
|
|
|
||||||
|
site (replacement: true, incompatible with parent)
|
||||||
"""
|
"""
|
||||||
doc_factory = factories.DocumentFactory(2, [1, 1])
|
doc_factory = factories.DocumentFactory(2, [1, 1])
|
||||||
documents = doc_factory.gen_test({})
|
documents = doc_factory.gen_test({})
|
||||||
@ -52,6 +56,10 @@ class TestDocumentLayeringReplacementNegative(
|
|||||||
"""Validate that a non-replacement document (i.e. regular document
|
"""Validate that a non-replacement document (i.e. regular document
|
||||||
without `replacement: true`) cannot have the same schema/name as
|
without `replacement: true`) cannot have the same schema/name as
|
||||||
another document.
|
another document.
|
||||||
|
|
||||||
|
global (replacement: false)
|
||||||
|
|
|
||||||
|
site (replacement: false)
|
||||||
"""
|
"""
|
||||||
doc_factory = factories.DocumentFactory(2, [1, 1])
|
doc_factory = factories.DocumentFactory(2, [1, 1])
|
||||||
documents = doc_factory.gen_test({})
|
documents = doc_factory.gen_test({})
|
||||||
@ -68,6 +76,10 @@ class TestDocumentLayeringReplacementNegative(
|
|||||||
def test_replacement_without_parent_raises_exc(self):
|
def test_replacement_without_parent_raises_exc(self):
|
||||||
"""Validate that attempting to do replacement without a parent document
|
"""Validate that attempting to do replacement without a parent document
|
||||||
raises an exception.
|
raises an exception.
|
||||||
|
|
||||||
|
None
|
||||||
|
|
|
||||||
|
site (replacement: true)
|
||||||
"""
|
"""
|
||||||
doc_factory = factories.DocumentFactory(2, [1, 1])
|
doc_factory = factories.DocumentFactory(2, [1, 1])
|
||||||
documents = doc_factory.gen_test({})
|
documents = doc_factory.gen_test({})
|
||||||
@ -80,9 +92,35 @@ class TestDocumentLayeringReplacementNegative(
|
|||||||
self.assertRaisesRegexp(errors.InvalidDocumentReplacement, error_re,
|
self.assertRaisesRegexp(errors.InvalidDocumentReplacement, error_re,
|
||||||
self._test_layering, documents)
|
self._test_layering, documents)
|
||||||
|
|
||||||
|
def test_replacement_with_parent_replace_true_raises_exc(self):
|
||||||
|
"""Validate that a parent document with replacement: true necessarily
|
||||||
|
fails if it itself doesn't have a parent.
|
||||||
|
|
||||||
|
None
|
||||||
|
|
|
||||||
|
global (replacement: true)
|
||||||
|
|
|
||||||
|
site
|
||||||
|
"""
|
||||||
|
doc_factory = factories.DocumentFactory(2, [1, 1])
|
||||||
|
documents = doc_factory.gen_test({})
|
||||||
|
|
||||||
|
documents[1]['metadata']['replacement'] = True
|
||||||
|
|
||||||
|
error_re = (r'Document replacement requires that the document with '
|
||||||
|
'`replacement: true` have a parent.')
|
||||||
|
self.assertRaisesRegexp(errors.InvalidDocumentReplacement, error_re,
|
||||||
|
self._test_layering, documents)
|
||||||
|
|
||||||
def test_replacement_that_is_replaced_raises_exc(self):
|
def test_replacement_that_is_replaced_raises_exc(self):
|
||||||
"""Validate that attempting replace a replacement document raises an
|
"""Validate that attempting to replace a replacement document raises an
|
||||||
exception.
|
exception.
|
||||||
|
|
||||||
|
global
|
||||||
|
|
|
||||||
|
region (replacement: true)
|
||||||
|
|
|
||||||
|
site (replacement: true)
|
||||||
"""
|
"""
|
||||||
doc_factory = factories.DocumentFactory(3, [1, 1, 1])
|
doc_factory = factories.DocumentFactory(3, [1, 1, 1])
|
||||||
documents = doc_factory.gen_test({}, region_abstract=False,
|
documents = doc_factory.gen_test({}, region_abstract=False,
|
||||||
@ -99,3 +137,25 @@ class TestDocumentLayeringReplacementNegative(
|
|||||||
'another document.')
|
'another document.')
|
||||||
self.assertRaisesRegexp(errors.InvalidDocumentReplacement, error_re,
|
self.assertRaisesRegexp(errors.InvalidDocumentReplacement, error_re,
|
||||||
self._test_layering, documents)
|
self._test_layering, documents)
|
||||||
|
|
||||||
|
def test_replacement_true_with_parent_replacement_true_raises_exc(self):
|
||||||
|
"""Validate that when documents have the same `metadata.name` and
|
||||||
|
`metadata.schema` existing in different layers without any of them
|
||||||
|
having `replacement = true` raises an exception
|
||||||
|
"""
|
||||||
|
doc_factory = factories.DocumentFactory(2, [1, 1])
|
||||||
|
documents = doc_factory.gen_test({})
|
||||||
|
|
||||||
|
for document in documents[1:]:
|
||||||
|
document['metadata']['name'] = 'foo'
|
||||||
|
document['schema'] = 'example/Kind/v1'
|
||||||
|
document['metadata']['replacement'] = False
|
||||||
|
if 'parentSelector' in document['metadata']['layeringDefinition']:
|
||||||
|
document['metadata']['layeringDefinition'].pop(
|
||||||
|
'parentSelector')
|
||||||
|
|
||||||
|
error_re = (r'Documents with the same name and schema existing in '
|
||||||
|
'different layers without any of them having '
|
||||||
|
'`replacement = true` cannot exist.*')
|
||||||
|
self.assertRaisesRegexp(errors.InvalidDocumentReplacement, error_re,
|
||||||
|
self._test_layering, documents)
|
||||||
|
@ -80,6 +80,24 @@ The following result in validation errors:
|
|||||||
* A replacement document cannot itself be replaced. That is, only one level
|
* A replacement document cannot itself be replaced. That is, only one level
|
||||||
of replacement is allowed.
|
of replacement is allowed.
|
||||||
|
|
||||||
|
Here are the following possible scenarios regarding child and parent
|
||||||
|
replacement values:
|
||||||
|
|
||||||
|
+-------------------------------------------------------------+
|
||||||
|
| Child | Parent | Status |
|
||||||
|
+=============================================================+
|
||||||
|
| True | True | Throws InvalidDocumentReplacement exception|
|
||||||
|
+-------------------------------------------------------------+
|
||||||
|
| False | True | Throws InvalidDocumentReplacement exception|
|
||||||
|
+-------------------------------------------------------------+
|
||||||
|
| True | False | Valid scenario |
|
||||||
|
+-------------------------------------------------------------+
|
||||||
|
| False | False | Throws InvalidDocumentReplacement exception|
|
||||||
|
+-------------------------------------------------------------+
|
||||||
|
|
||||||
|
Examples
|
||||||
|
--------
|
||||||
|
|
||||||
Note that each key in the examples below is *mandatory* and that the
|
Note that each key in the examples below is *mandatory* and that the
|
||||||
``parentSelector`` labels should be able to select the parent to be replaced.
|
``parentSelector`` labels should be able to select the parent to be replaced.
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user