Merge "Validate additional 'metadata.replacement' scenarios"
This commit is contained in:
commit
265a5bf18f
|
@ -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