Improve validation error messages returned by Deckhand
Currently the format of the validation error messages returned by the Deckhand Validation API is lacking. The new response is a dictionary with the following keys: * validation_schema: The schema body that was used to validate the document. * schema_path: The JSON path in the schema where the failure originated. * name: The document name. * schema: The document schema. * path: The JSON path in the document where the failure originated. * error_section: The "section" in the document above which the error originated (i.e. the dict in which ``path`` is found). * message: The error message returned by the ``jsonschema`` validator. This PS updates the document validation module and associated unit tests to return and verify the above format. Change-Id: I9ef1c36db85233cbfb866dea786228ef1416468c
This commit is contained in:
parent
a3e96dac29
commit
021090516b
|
@ -22,6 +22,7 @@ import six
|
|||
from deckhand.engine import document_wrapper
|
||||
from deckhand.engine.schema import base_schema
|
||||
from deckhand.engine.schema import v1_0
|
||||
from deckhand.engine.secrets_manager import SecretsSubstitution
|
||||
from deckhand import errors
|
||||
from deckhand import types
|
||||
from deckhand import utils
|
||||
|
@ -131,7 +132,7 @@ class SchemaValidator(BaseValidator):
|
|||
:rtype: dict
|
||||
|
||||
"""
|
||||
schema_prefix, schema_version = get_schema_parts(document)
|
||||
schema_prefix, schema_version = _get_schema_parts(document)
|
||||
matching_schemas = []
|
||||
relevant_schemas = self._schema_map.get(schema_version, {})
|
||||
for candidae_schema_prefix, schema in relevant_schemas.items():
|
||||
|
@ -194,9 +195,8 @@ class SchemaValidator(BaseValidator):
|
|||
'Failed schema validation for document [%s] %s. '
|
||||
'Details: %s.', document.schema, document.name,
|
||||
error.message)
|
||||
parent_path = root_path + '.'.join(
|
||||
[six.text_type(x) for x in error.path])
|
||||
yield error.message, parent_path
|
||||
yield _generate_validation_error_output(
|
||||
schema_to_use, document, error, root_path)
|
||||
|
||||
|
||||
class DataSchemaValidator(SchemaValidator):
|
||||
|
@ -218,7 +218,7 @@ class DataSchemaValidator(SchemaValidator):
|
|||
continue
|
||||
if 'data' not in data_schema:
|
||||
continue
|
||||
schema_prefix, schema_version = get_schema_parts(data_schema,
|
||||
schema_prefix, schema_version = _get_schema_parts(data_schema,
|
||||
'metadata.name')
|
||||
|
||||
class Schema(object):
|
||||
|
@ -233,7 +233,7 @@ class DataSchemaValidator(SchemaValidator):
|
|||
LOG.info('Skipping schema validation for abstract document [%s]: '
|
||||
'%s.', document.schema, document.name)
|
||||
return False
|
||||
schema_prefix, schema_version = get_schema_parts(document)
|
||||
schema_prefix, schema_version = _get_schema_parts(document)
|
||||
return schema_prefix in self._schema_map.get(schema_version, {})
|
||||
|
||||
def validate(self, document):
|
||||
|
@ -328,7 +328,7 @@ class DocumentValidation(object):
|
|||
|
||||
supported_schema_list = self._get_supported_schema_list()
|
||||
document_schema = None if not document.get('schema') else '/'.join(
|
||||
get_schema_parts(document))
|
||||
_get_schema_parts(document))
|
||||
if document_schema not in supported_schema_list:
|
||||
error_msg = ("The provided document schema %s is invalid. "
|
||||
"Supported schemas include: %s" % (
|
||||
|
@ -344,15 +344,10 @@ class DocumentValidation(object):
|
|||
|
||||
for validator in self._validators:
|
||||
if validator.matches(document):
|
||||
error_messages = validator.validate(document)
|
||||
if error_messages:
|
||||
for error_msg, error_path in error_messages:
|
||||
result['errors'].append({
|
||||
'schema': document.schema,
|
||||
'name': document.name,
|
||||
'message': error_msg,
|
||||
'path': error_path
|
||||
})
|
||||
error_outputs = validator.validate(document)
|
||||
if error_outputs:
|
||||
for error_output in error_outputs:
|
||||
result['errors'].append(error_output)
|
||||
|
||||
if result['errors']:
|
||||
result.setdefault('status', 'failure')
|
||||
|
@ -420,10 +415,62 @@ class DocumentValidation(object):
|
|||
return validations
|
||||
|
||||
|
||||
def get_schema_parts(document, schema_key='schema'):
|
||||
def _get_schema_parts(document, schema_key='schema'):
|
||||
schema_parts = utils.jsonpath_parse(document, schema_key).split('/')
|
||||
schema_prefix = '/'.join(schema_parts[:2])
|
||||
schema_version = schema_parts[2]
|
||||
if schema_version.endswith('.0'):
|
||||
schema_version = schema_version[:-2]
|
||||
return schema_prefix, schema_version
|
||||
|
||||
|
||||
def _generate_validation_error_output(schema, document, error, root_path):
|
||||
"""Returns a formatted output with necessary details for debugging why
|
||||
a validation failed.
|
||||
|
||||
The response is a dictionary with the following keys:
|
||||
|
||||
* validation_schema: The schema body that was used to validate the
|
||||
document.
|
||||
* schema_path: The JSON path in the schema where the failure originated.
|
||||
* name: The document name.
|
||||
* schema: The document schema.
|
||||
* path: The JSON path in the document where the failure originated.
|
||||
* error_section: The "section" in the document above which the error
|
||||
originated (i.e. the dict in which ``path`` is found).
|
||||
* message: The error message returned by the ``jsonschema`` validator.
|
||||
|
||||
:returns: Dictionary in the above format.
|
||||
"""
|
||||
path_to_error_in_document = root_path + '.'.join(
|
||||
[str(x) for x in error.path])
|
||||
path_to_error_in_schema = '.' + '.'.join(
|
||||
[str(x) for x in error.schema_path])
|
||||
|
||||
parent_path_to_error_in_document = '.'.join(
|
||||
path_to_error_in_document.split('.')[:-1]) or '.'
|
||||
try:
|
||||
parent_error_section = utils.jsonpath_parse(
|
||||
document, parent_path_to_error_in_document)
|
||||
if 'data' in parent_error_section:
|
||||
# NOTE(fmontei): Because validation is performed on fully rendered
|
||||
# documents, it is necessary to omit the parts of the data section
|
||||
# where substitution may have occurred to avoid exposing any
|
||||
# secrets. While this may make debugging a few validation failures
|
||||
# more difficult, it is a necessary evil.
|
||||
SecretsSubstitution.sanitize_potential_secrets(document)
|
||||
except Exception:
|
||||
parent_error_section = (
|
||||
'Failed to find parent section above where error occurred.')
|
||||
|
||||
error_output = {
|
||||
'validation_schema': schema.schema,
|
||||
'schema_path': path_to_error_in_schema,
|
||||
'name': document.name,
|
||||
'schema': document.schema,
|
||||
'path': path_to_error_in_document,
|
||||
'error_section': parent_error_section,
|
||||
'message': error.message
|
||||
}
|
||||
|
||||
return error_output
|
||||
|
|
|
@ -216,3 +216,17 @@ class SecretsSubstitution(object):
|
|||
|
||||
substituted_docs.append(document)
|
||||
return substituted_docs
|
||||
|
||||
@staticmethod
|
||||
def sanitize_potential_secrets(document):
|
||||
"""Sanitize all secret data that may have been substituted into the
|
||||
document. Uses references in ``document.substitutions`` to determine
|
||||
which values to sanitize. Only meaningful to call this on post-rendered
|
||||
documents.
|
||||
|
||||
:param DocumentDict document: Document to sanitize.
|
||||
"""
|
||||
safe_message = 'Sanitized to avoid exposing secret.'
|
||||
for sub in document.substitutions:
|
||||
utils.jsonpath_replace(document['data'], safe_message,
|
||||
sub['dest']['path'])
|
||||
|
|
|
@ -17,6 +17,7 @@ import yaml
|
|||
|
||||
from oslo_config import cfg
|
||||
|
||||
from deckhand.engine.schema.v1_0 import document_schema
|
||||
from deckhand import factories
|
||||
from deckhand.tests import test_utils
|
||||
from deckhand.tests.unit.control import base as test_base
|
||||
|
@ -555,19 +556,29 @@ class TestValidationsController(test_base.BaseControllerTest):
|
|||
self.assertEqual(expected_body, body)
|
||||
|
||||
# Validate that both expected errors are present for validation.
|
||||
expected_errors = [
|
||||
{
|
||||
'message': "'layeringDefinition' is a required property",
|
||||
'name': 'test_doc',
|
||||
'schema': 'example/foo/v1',
|
||||
'path': '.metadata'
|
||||
}, {
|
||||
'message': "'fail' is not of type 'integer'",
|
||||
'name': 'test_doc',
|
||||
'schema': 'example/foo/v1',
|
||||
'path': '.data.a'
|
||||
}
|
||||
]
|
||||
expected_errors = [{
|
||||
'error_section': {
|
||||
'data': {'a': 'fail'},
|
||||
'metadata': {'labels': {'global': 'global1'},
|
||||
'name': 'test_doc',
|
||||
'schema': 'metadata/Document/v1.0'},
|
||||
'schema': 'example/foo/v1'
|
||||
},
|
||||
'name': 'test_doc',
|
||||
'path': '.metadata',
|
||||
'schema': 'example/foo/v1',
|
||||
'message': "'layeringDefinition' is a required property",
|
||||
'validation_schema': document_schema.schema,
|
||||
'schema_path': '.properties.metadata.required'
|
||||
}, {
|
||||
'error_section': {'a': 'fail'},
|
||||
'name': 'test_doc',
|
||||
'path': '.data.a',
|
||||
'schema': 'example/foo/v1',
|
||||
'message': "'fail' is not of type 'integer'",
|
||||
'validation_schema': schema_to_use,
|
||||
'schema_path': '.properties.a.type'
|
||||
}]
|
||||
resp = self.app.simulate_get(
|
||||
'/api/v1.0/revisions/%s/validations/%s/entries/0' % (
|
||||
revision_id, types.DECKHAND_SCHEMA_VALIDATION),
|
||||
|
@ -598,6 +609,15 @@ class TestValidationsController(test_base.BaseControllerTest):
|
|||
},
|
||||
'required': ['a']
|
||||
}
|
||||
expected_errors = [{
|
||||
'error_section': {'a': 'fail'},
|
||||
'name': 'test_doc',
|
||||
'path': '.data.a',
|
||||
'schema': 'example/foo/v1',
|
||||
'message': "'fail' is not of type 'integer'",
|
||||
'validation_schema': schema_to_use,
|
||||
'schema_path': '.properties.a.type'
|
||||
}]
|
||||
data_schema = data_schema_factory.gen_test(
|
||||
metadata_name, data=schema_to_use)
|
||||
|
||||
|
@ -652,11 +672,15 @@ class TestValidationsController(test_base.BaseControllerTest):
|
|||
self.assertEqual(200, resp.status_code)
|
||||
body = yaml.safe_load(resp.text)
|
||||
expected_errors = [{
|
||||
'schema': 'example/foo/v1',
|
||||
'error_section': {'a': 'fail'},
|
||||
'name': 'test_doc',
|
||||
'path': '.data.a',
|
||||
'schema': 'example/foo/v1',
|
||||
'message': "'fail' is not of type 'integer'",
|
||||
'path': '.data.a'
|
||||
'validation_schema': schema_to_use,
|
||||
'schema_path': '.properties.a.type'
|
||||
}]
|
||||
|
||||
self.assertIn('errors', body)
|
||||
self.assertEqual(expected_errors, body['errors'])
|
||||
|
||||
|
@ -704,10 +728,13 @@ class TestValidationsController(test_base.BaseControllerTest):
|
|||
self.assertEqual(200, resp.status_code)
|
||||
body = yaml.safe_load(resp.text)
|
||||
expected_errors = [{
|
||||
'schema': document['schema'],
|
||||
'error_section': document,
|
||||
'name': document['metadata']['name'],
|
||||
'path': '.',
|
||||
'schema': document['schema'],
|
||||
'message': "'data' is a required property",
|
||||
'path': '.'
|
||||
'validation_schema': document_schema.schema,
|
||||
'schema_path': '.required'
|
||||
}]
|
||||
self.assertIn('errors', body)
|
||||
self.assertEqual(expected_errors, body['errors'])
|
||||
|
|
|
@ -63,3 +63,28 @@ class TestDocumentValidation(engine_test_base.TestDocumentValidationBase):
|
|||
self.assertTrue(mock_log.info.called)
|
||||
self.assertIn("Skipping schema validation for abstract document",
|
||||
mock_log.info.mock_calls[0][1][0])
|
||||
|
||||
@mock.patch.object(document_validation, 'jsonschema', autospec=True)
|
||||
def test_validation_failure_does_not_expose_secrets(self, mock_jsonschema):
|
||||
m_args = mock.Mock()
|
||||
mock_jsonschema.Draft4Validator(m_args).iter_errors.side_effect = [
|
||||
# Return empty list of errors for base schema validator and pretend
|
||||
# that 1 error is returned for next validator.
|
||||
[], [mock.Mock(path=[], schema_path=[])]
|
||||
]
|
||||
test_document = self._read_data('sample_document')
|
||||
for sub in test_document['metadata']['substitutions']:
|
||||
substituted_data = utils.jsonpath_replace(
|
||||
test_document['data'], 'scary-secret', sub['dest']['path'])
|
||||
test_document['data'].update(substituted_data)
|
||||
self.assertEqual(
|
||||
'scary-secret', utils.jsonpath_parse(test_document['data'],
|
||||
sub['dest']['path']))
|
||||
|
||||
validations = document_validation.DocumentValidation(
|
||||
test_document).validate_all()
|
||||
|
||||
self.assertEqual(2, len(validations[0]['errors']))
|
||||
self.assertIn('Sanitized to avoid exposing secret.',
|
||||
str(validations[0]['errors'][-1]))
|
||||
self.assertNotIn('scary-secret.', str(validations[0]['errors'][-1]))
|
||||
|
|
|
@ -44,6 +44,11 @@ class TestDocumentValidationNegative(test_base.TestDocumentValidationBase):
|
|||
self.assertEqual(types.DECKHAND_SCHEMA_VALIDATION,
|
||||
validations[-1]['name'])
|
||||
self.assertEqual(1, len(validations[-1]['errors']))
|
||||
|
||||
for key in ('name', 'schema', 'path', 'error_section',
|
||||
'validation_schema', 'schema_path', 'message'):
|
||||
self.assertIn(key, validations[-1]['errors'][-1])
|
||||
|
||||
self.assertEqual(expected['metadata']['name'],
|
||||
validations[-1]['errors'][-1]['name'])
|
||||
self.assertEqual(expected['schema'],
|
||||
|
|
Loading…
Reference in New Issue