From b29511900da89e886125f5d6c294e5adcbadc413 Mon Sep 17 00:00:00 2001 From: Graham Welch Date: Tue, 15 Dec 2015 15:12:33 -0800 Subject: [PATCH 1/6] Add references library that handles outputs, and stop schema validation from validating properties who's value is a reference. --- expandybird/expansion/references.py | 221 ++++++++++++++ expandybird/expansion/references_test.py | 274 ++++++++++++++++++ expandybird/expansion/schema_validation.py | 9 +- .../expansion/schema_validation_test.py | 51 +++- .../test/schemas/nested_objects.py.schema | 20 ++ 5 files changed, 573 insertions(+), 2 deletions(-) create mode 100644 expandybird/expansion/references.py create mode 100644 expandybird/expansion/references_test.py create mode 100644 expandybird/test/schemas/nested_objects.py.schema diff --git a/expandybird/expansion/references.py b/expandybird/expansion/references.py new file mode 100644 index 000000000..6fc46c09a --- /dev/null +++ b/expandybird/expansion/references.py @@ -0,0 +1,221 @@ +"""Util to handle references during expansion.""" + +import jsonpath +import re + + +# Generally the regex is for this pattern: $(ref.NAME.PATH) +# NOTE: This will find matches when a reference is present, but the 2nd group +# does NOT necessarily match the 'path' in the reference. +REF_PATTERN = re.compile(r'\$\(ref\.(.*?)\.(.*)\)') + +# Regex for the beginning of a reference. +REF_PREFIX_PATTERN = re.compile(r'\$\(ref\.') + + +def HasReference(string): + """Returns true if the string contains a reference. + + We are only looking for the first part of a reference, from there we assume + the user meant to use a reference, and will fail at a later point if no + complete reference is found. + + Args: + string: The string to parse to see if it contains '$(ref.'. + + Returns: + True if there is at least one reference inside the string. + + Raises: + ExpansionReferenceError: If we see '$(ref.' but do not find a complete + reference, we raise this error. + """ + return ReferenceMatcher(string).FindReference() + + +def _BuildReference(name, path): + """Takes name and path and returns '$(ref.name.path)'. + + Args: + name: String, name of the resource being referenced. + path: String, jsonPath to the value being referenced. + + Returns: + String, the complete reference string in the expected format. + """ + return '$(ref.%s.%s)' % (name, path) + + +def _ExtractWithJsonPath(ref_obj, name, path, raise_exception=True): + """Given a path and an object, use jsonpath to extract the value. + + Args: + ref_obj: Dict obj, the thing being referenced. + name: Name of the resource being referenced, for the error message. + path: Path to follow on the ref_obj to get the desired value. + raise_exception: boolean, set to False and this function will return None + if no value was found at the path instead of throwing an exception. + + Returns: + Either the value found at the path, or if raise_exception=False, None. + + Raises: + ExpansionReferenceError: if there was a error when evaluation the path, or + no value was found. + """ + try: + result = jsonpath.jsonpath(ref_obj, path) + # jsonpath should either return a list or False + if not isinstance(result, list): + if raise_exception: + raise Exception('No value found.') + return None + # If jsonpath returns a list of a single item, it is lying. + # It really found that item, and put it in a list. + # If the reference is to a list, jsonpath will return a list of a list. + if len(result) == 1: + return result[0] + # But if jsonpath returns a list with multiple elements, the path involved + # wildcards, and the user expects a list of results. + return result + # This will usually be an IndexOutOfBounds error, but not always... + except Exception as e: # pylint: disable=broad-except + if raise_exception: + raise ExpansionReferenceError(_BuildReference(name, path), e.message) + return None + + +def PopulateReferences(node, output_map): + return _TraverseNode(node, None, output_map) + + +def _TraverseNode(node, list_references=None, output_map=None): + """Traverse a dict/list/element to find and resolve references. + + Same as DocumentReferenceHandler.java. This function traverses a dictionary + that can contain dicts, lists, and elements. + + Args: + node: Object to traverse: dict, list, or string + list_references: If present, we will append all references we find to it. + References will be in the form of a (name, path) tuple. + output_map: Map of resource name to map of output object name to output + value. If present, we will replace references with the values they + reference. + + Returns: + The node. If we were provided an output_map, we'll replace references with + the value they reference. + """ + if isinstance(node, dict): + for key in node: + node[key] = _TraverseNode(node[key], list_references, output_map) + elif isinstance(node, list): + for i in range(len(node)): + node[i] = _TraverseNode(node[i], list_references, output_map) + elif isinstance(node, str): + rm = ReferenceMatcher(node) + while rm.FindReference(): + if list_references is not None: + list_references.append((rm.name, rm.path)) + if output_map is not None: + if rm.name not in output_map: + continue + # It is possible that an output value and real resource share a name. + # In this case, a path could be valid for the real resource but not the + # output. So we don't fail, we let the reference exists as is. + value = _ExtractWithJsonPath(output_map[rm.name], rm.name, rm.path, + raise_exception=True) + if value is not None: + node = node.replace(_BuildReference(rm.name, rm.path), value) + return node + + +class ReferenceMatcher(object): + """Finds and extracts references from strings. + + Same as DocumentReferenceHandler.java. This class is meant to be similar to + the re2 matcher class, but specifically tuned for references. + """ + content = None + name = None + path = None + + def __init__(self, content): + self.content = content + + def FindReference(self): + """Returns True if the string contains a reference and saves it. + + Returns: + True if the content still contains a reference. If so, it also updates the + name and path values to that of the most recently found reference. At the + same time it moves the pointer in the content forward to be ready to find + the next reference. + + Raises: + ExpansionReferenceError: If we see '$(ref.' but do not find a complete + reference, we raise this error. + """ + + # First see if the content contains '$(ref.' + if not REF_PREFIX_PATTERN.search(self.content): + return False + + # If so, then we say there is a reference here. + # Next make sure we find NAME and some PATH with the close paren + match = REF_PATTERN.search(self.content) + if not match: + # Has '$(ref.' but not '$(ref.NAME.PATH)' + raise ExpansionReferenceError(self.content, 'Malformed reference.') + + # The regex matcher can only tell us that a complete reference exists. + # We need to count parentheses to find the end of the reference. + # Consider "$(ref.NAME.path())())" which is a string containing a reference + # and ending with "())" + + open_group = 1 # Count the first '(' in '$(ref...' + end_ref = 0 # To hold the position of the end of the reference + end_name = match.end(1) # The position of the end of the name + + # Iterate through the path until we find the matching close paren to the + # open paren that started the reference. + for i in xrange(end_name, len(self.content)): + c = self.content[i] + if c == '(': + open_group += 1 + elif c == ')': + open_group -= 1 + + # Once we have matched all of our open parens, we have found the end. + if open_group == 0: + end_ref = i + break + + if open_group != 0: + # There are unmatched parens. + raise ExpansionReferenceError(self.content, 'Malformed reference.') + + # Save the name + self.name = match.group(1) + # Skip the period after name, and save the path + self.path = self.content[end_name + 1: end_ref] + + # Move the content forward to be ready to find the next reference + self.content = self.content[end_ref:] + + return True + + +class ExpansionReferenceError(Exception): + """Exception raised when jsonPath cannot find the referenced value. + + Attributes: + reference: the reference processed that results in the error + message: the detailed message of the error + """ + + def __init__(self, reference, message): + self.reference = reference + self.message = message + ' Reference: ' + str(reference) + super(ExpansionReferenceError, self).__init__(self.message) diff --git a/expandybird/expansion/references_test.py b/expandybird/expansion/references_test.py new file mode 100644 index 000000000..dd1af867e --- /dev/null +++ b/expandybird/expansion/references_test.py @@ -0,0 +1,274 @@ +###################################################################### +# Copyright 2015 The Kubernetes Authors All 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. +###################################################################### +"""Basic unit tests for template expansion references library.""" + +import unittest +from references import _ExtractWithJsonPath +from references import _TraverseNode +from references import ExpansionReferenceError +from references import HasReference +from references import ReferenceMatcher + + +class ReferencesTest(unittest.TestCase): + + # Tests for HasReference + + def testBasicReference(self): + self.assertTrue(HasReference('$(ref.name.path)')) + + def testEmbeddedReference(self): + self.assertTrue(HasReference('contains reference $(ref.name.path) EOM')) + + def testComplexPath(self): + self.assertTrue(HasReference('$(ref.name.path[0].to().very.cool["thing"])')) + + def testComplexName(self): + self.assertTrue(HasReference('$(ref.name-is-superCool.path)')) + + def testMissingGroupClose(self): + try: + HasReference('almost a reference $(ref.name.path') + self.Fail('Expected Reference exception') + except ExpansionReferenceError as e: + self.assertTrue('Malformed' in e.message) + self.assertTrue('$(ref.name.path' in e.message) + + def testMissingGroupOpen(self): + # Not close enough to find a match + self.assertFalse(HasReference('almost a reference $ref.name.path)')) + + def testMissingPath(self): + try: + self.assertTrue(HasReference('almost a reference $(ref.name)')) + self.Fail('Expected Reference exception') + except ExpansionReferenceError as e: + self.assertTrue('Malformed' in e.message) + self.assertTrue('$(ref.name)' in e.message) + + def testUnmatchedParens(self): + try: + self.assertTrue(HasReference('almost a reference $(ref.name.path()')) + self.Fail('Expected Reference exception') + except ExpansionReferenceError as e: + self.assertTrue('Malformed' in e.message) + self.assertTrue('$(ref.name.path()' in e.message) + + def testMissingRef(self): + self.assertFalse(HasReference('almost a reference $(name.path)')) + + # Test for ReferenceMatcher + + def testMatchBasic(self): + matcher = ReferenceMatcher('$(ref.NAME.PATH)') + self.assertTrue(matcher.FindReference()) + self.assertEquals(matcher.name, 'NAME') + self.assertEquals(matcher.path, 'PATH') + self.assertFalse(matcher.FindReference()) + + def testMatchComplexPath(self): + matcher = ReferenceMatcher('inside a $(ref.NAME.path[?(@.price<10)].val)!') + self.assertTrue(matcher.FindReference()) + self.assertEquals(matcher.name, 'NAME') + self.assertEquals(matcher.path, 'path[?(@.price<10)].val') + self.assertFalse(matcher.FindReference()) + + def testMatchInString(self): + matcher = ReferenceMatcher('inside a $(ref.NAME.PATH) string') + self.assertTrue(matcher.FindReference()) + self.assertEquals(matcher.name, 'NAME') + self.assertEquals(matcher.path, 'PATH') + self.assertFalse(matcher.FindReference()) + + def testMatchTwo(self): + matcher = ReferenceMatcher('two $(ref.NAME1.PATH1) inside ' + 'a $(ref.NAME2.PATH2) string') + self.assertTrue(matcher.FindReference()) + self.assertEquals(matcher.name, 'NAME1') + self.assertEquals(matcher.path, 'PATH1') + self.assertTrue(matcher.FindReference()) + self.assertEquals(matcher.name, 'NAME2') + self.assertEquals(matcher.path, 'PATH2') + self.assertFalse(matcher.FindReference()) + + def testMatchGoodAndBad(self): + matcher = ReferenceMatcher('$(ref.NAME.PATH) good and $(ref.NAME.PATH bad') + self.assertTrue(matcher.FindReference()) + self.assertEquals(matcher.name, 'NAME') + self.assertEquals(matcher.path, 'PATH') + try: + matcher.FindReference() + self.Fail('Expected Reference exception') + except ExpansionReferenceError as e: + self.assertTrue('Malformed' in e.message) + self.assertTrue('$(ref.NAME.PATH bad' in e.message) + + def testAlmostMatch(self): + matcher = ReferenceMatcher('inside a $(ref.NAME.PATH with no close paren') + try: + matcher.FindReference() + self.Fail('Expected Reference exception') + except ExpansionReferenceError as e: + self.assertTrue('Malformed' in e.message) + self.assertTrue('$(ref.NAME.PATH ' in e.message) + + # Tests for _TraverseNode + + def testFindAllReferences(self): + ref_list = [] + node = {'a': ['a $(ref.name1.path1) string', + '$(ref.name2.path2)', + 123,], + 'b': {'a1': 'another $(ref.name3.path3) string',}, + 'c': 'yet another $(ref.name4.path4) string',} + + traversed_node = _TraverseNode(node, ref_list, None) + + self.assertEquals(node, traversed_node) + self.assertEquals(4, len(ref_list)) + self.assertTrue(('name1', 'path1') in ref_list) + self.assertTrue(('name2', 'path2') in ref_list) + self.assertTrue(('name3', 'path3') in ref_list) + self.assertTrue(('name4', 'path4') in ref_list) + + def testReplaceReference(self): + ref_map = {'name1': {'path1a': '1a', + 'path1b': '1b',}, + 'name2': {'path2a': '2a',},} + + node = {'a': ['a $(ref.name1.path1a) string', + '$(ref.name2.path2a)', + 123,], + 'b': {'a1': 'another $(ref.name1.path1b) string',}, + 'c': 'yet another $(ref.name2.path2a)$(ref.name2.path2a) string',} + + expt = {'a': ['a 1a string', + '2a', + 123,], + 'b': {'a1': 'another 1b string',}, + 'c': 'yet another 2a2a string',} + + self.assertEquals(expt, _TraverseNode(node, None, ref_map)) + + def testReplaceNotFoundReferencePath(self): + ref_map = {'name1': {'path1a': '1a',},} + + node = {'a': ['a $(ref.name1.path1a) string', + 'b $(ref.name1.path1b)', + 'c $(ref.name2.path2a)'],} + + try: + _TraverseNode(node, None, ref_map) + self.Fail('Expected ExpansionReferenceError') + except ExpansionReferenceError as e: + self.assertTrue('No value found' in e.message) + self.assertTrue('$(ref.name1.path1b)' in e.message) + + def testReplaceNotFoundReferenceName(self): + ref_map = {'name1': {'path1a': '1a',},} + + node = {'a': ['a $(ref.name1.path1a) string', + 'c $(ref.name2.path2a)'],} + + expt = {'a': ['a 1a string', + 'c $(ref.name2.path2a)'],} + + self.assertEquals(expt, _TraverseNode(node, None, ref_map)) + + # Tests for _ExtractWithJsonPath + + def testExtractFromList(self): + ref_map = {'a': ['one', 'two', 'three',],} + + self.assertEquals('two', _ExtractWithJsonPath(ref_map, 'foo', 'a[1]')) + + def testExtractFromMap(self): + ref_map = {'a': {'b': {'c': 'd'}}} + + self.assertEquals('d', _ExtractWithJsonPath(ref_map, 'foo', 'a.b.c')) + + def testExtractList(self): + ref_map = {'a': ['one', 'two', 'three',],} + + self.assertEquals(['one', 'two', 'three',], + _ExtractWithJsonPath(ref_map, 'foo', 'a')) + + def testExtractListOfSingleItem(self): + ref_map = {'a': ['one'],} + + self.assertEquals(['one'], _ExtractWithJsonPath(ref_map, 'foo', 'a')) + + def testExtractListWithWildcard(self): + ref_map = {'a': ['one', 'two', 'three',],} + + self.assertEquals(['one', 'two', 'three',], + _ExtractWithJsonPath(ref_map, 'foo', 'a[*]')) + + def testExtractMap(self): + ref_map = {'a': {'b': {'c': 'd'}}} + + self.assertEquals({'c': 'd'}, _ExtractWithJsonPath(ref_map, 'foo', 'a.b')) + + def testExtractFalse(self): + ref_map = {'a': False} + + self.assertEquals(False, _ExtractWithJsonPath(ref_map, 'foo', 'a')) + + def testExtractFail_BadIndex(self): + ref_map = {'a': ['one', 'two', 'three',],} + + # IndexError + try: + _ExtractWithJsonPath(ref_map, 'foo', 'a[3]') + self.fail('Expected Reference error') + except ExpansionReferenceError as e: + self.assertTrue('foo.a[3]' in e.message) + self.assertTrue('index out of range' in e.message) + + self.assertFalse(_ExtractWithJsonPath(ref_map, 'foo', 'a[3]', + raise_exception=False)) + + def testExtractFail_NotAList(self): + ref_map = {'a': {'b': {'c': 'd'}}} + + try: + _ExtractWithJsonPath(ref_map, 'foo', 'a.b[0]') + self.fail('Expected Reference error') + except ExpansionReferenceError as e: + self.assertTrue('foo.a.b[0]' in e.message) + self.assertTrue('No value found.' in e.message) + + self.assertFalse(_ExtractWithJsonPath(ref_map, 'foo', 'a.b[0]', + raise_exception=False)) + + def testExtractFail_BadKey(self): + ref_map = {'a': {'b': {'c': 'd'}}} + + self.assertFalse(_ExtractWithJsonPath(ref_map, 'foo', 'a.b.d', + raise_exception=False)) + + def testExtractFail_NoObject(self): + ref_map = {'a': {'b': {'c': 'd'}}} + + self.assertFalse(_ExtractWithJsonPath(ref_map, 'foo', 'a.b.c.d', + raise_exception=False)) + + def testExtractFail_MalformedPath(self): + ref_map = {'a': {'b': {'c': 'd'}}} + + self.assertFalse(_ExtractWithJsonPath(ref_map, 'foo', 'a.b[2', + raise_exception=False)) + + +if __name__ == '__main__': + unittest.main() diff --git a/expandybird/expansion/schema_validation.py b/expandybird/expansion/schema_validation.py index f82b94ff8..1e8aa18a1 100644 --- a/expandybird/expansion/schema_validation.py +++ b/expandybird/expansion/schema_validation.py @@ -16,6 +16,7 @@ import jsonschema import yaml +import references import schema_validation_utils @@ -60,6 +61,12 @@ IMPORT_SCHEMA_VALIDATOR = jsonschema.Draft4Validator( yaml.safe_load(IMPORT_SCHEMA)) +def _FilterReferences(error_generator): + for error in error_generator: + if not references.HasReference(str(error.instance)): + yield error + + def _ValidateSchema(schema, validating_imports, schema_name, template_name): """Validate that the passed in schema file is correctly formatted. @@ -164,7 +171,7 @@ def Validate(properties, schema_name, template_name, imports): list(DEFAULT_SETTER(schema).iter_errors(properties)) # Now that we have default values, validate the properties - errors.extend(list(VALIDATOR(schema).iter_errors(properties))) + errors.extend(_FilterReferences(VALIDATOR(schema).iter_errors(properties))) if errors: raise ValidationErrors(schema_name, template_name, errors) diff --git a/expandybird/expansion/schema_validation_test.py b/expandybird/expansion/schema_validation_test.py index 3f8f4b38f..9a8c765c3 100644 --- a/expandybird/expansion/schema_validation_test.py +++ b/expandybird/expansion/schema_validation_test.py @@ -45,7 +45,7 @@ def ImportsRawValidate(raw_properties, schema_name, import_map): class SchemaValidationTest(unittest.TestCase): """Tests of the schema portion of the template expansion library.""" - + def testDefaults(self): schema_name = 'defaults.jinja.schema' schema = ReadTestFile(schema_name) @@ -612,5 +612,54 @@ class SchemaValidationTest(unittest.TestCase): self.assertIn("is not of type 'array' at ['imports']", e.message) self.assertIn("is not of type u'array' at [u'required']", e.message) + def testNoValidateReference_Simple(self): + schema = """ + properties: + number: + type: integer + """ + properties = """ + number: $(ref.foo.size) + """ + self.assertEquals(yaml.safe_load(properties), + RawValidate(properties, 'schema', schema)) + + def testNoValidateReference_OtherErrorNotFiltered(self): + schema = """ + properties: + number: + type: integer + also-number: + type: integer + """ + properties = """ + number: $(ref.foo.size) + also-number: not a number + """ + + try: + RawValidate(properties, 'schema', schema) + self.fail('Validation should fail') + except schema_validation.ValidationErrors as e: + self.assertEquals(1, len(e.errors)) + + def testNoValidateReference_NestedError(self): + schema_name = 'nested_objects.py.schema' + schema = ReadTestFile(schema_name) + properties = """ + one: + name: my-database + size: $(ref.other-database.size) + two: + name: other-database + size: really big + """ + try: + RawValidate(properties, schema_name, schema) + self.fail('Validation should fail') + except schema_validation.ValidationErrors as e: + self.assertEqual(1, len(e.errors)) + self.assertIn("is not of type 'integer' at ['two', 'size']", e.message) + if __name__ == '__main__': unittest.main() diff --git a/expandybird/test/schemas/nested_objects.py.schema b/expandybird/test/schemas/nested_objects.py.schema new file mode 100644 index 000000000..c48d646e7 --- /dev/null +++ b/expandybird/test/schemas/nested_objects.py.schema @@ -0,0 +1,20 @@ +info: + title: Schema with properties that are themselves objects + +imports: + +properties: + one: + type: object + properties: + name: + type: string + size: + type: integer + two: + type: object + properties: + name: + type: string + size: + type: integer From 2ca729923a9be3bdc68de636734891aff4dfbfa4 Mon Sep 17 00:00:00 2001 From: Graham Welch Date: Tue, 15 Dec 2015 15:54:35 -0800 Subject: [PATCH 2/6] Add support for Template Outputs. Outputs can be referenced using normal reference syntax. --- expandybird/expansion/expansion.py | 149 +++++++++++++-- expandybird/expansion/expansion_test.py | 173 +++++++++++++++++- expandybird/expansion/sandbox_loader.py | 5 +- expandybird/requirements.txt | 1 + .../test/templates/outputs/backend.jinja | 9 + .../templates/outputs/chain_multiple.yaml | 8 + .../outputs/chain_multiple_result.yaml | 23 +++ .../test/templates/outputs/chain_outputs.yaml | 7 + .../outputs/chain_outputs_result.yaml | 17 ++ .../test/templates/outputs/conditional.jinja | 9 + .../test/templates/outputs/conditional.yaml | 7 + .../templates/outputs/conditional_result.yaml | 18 ++ .../templates/outputs/consume_multiple.yaml | 8 + .../outputs/consume_multiple_result.yaml | 32 ++++ .../templates/outputs/consume_output.yaml | 8 + .../outputs/consume_output_result.yaml | 20 ++ .../test/templates/outputs/frontend.jinja | 9 + .../templates/outputs/instance_builder.jinja | 8 + .../test/templates/outputs/kubernetes.jinja | 5 + .../test/templates/outputs/list_output.jinja | 9 + .../test/templates/outputs/list_output.yaml | 12 ++ .../templates/outputs/list_output_result.yaml | 26 +++ .../test/templates/outputs/one_consume.jinja | 12 ++ .../test/templates/outputs/one_simple.jinja | 7 + .../test/templates/outputs/output_one.jinja | 5 + .../templates/outputs/output_template.jinja | 5 + .../templates/outputs/output_template.yaml | 5 + .../test/templates/outputs/simple.jinja | 6 + .../test/templates/outputs/simple.yaml | 6 + .../test/templates/outputs/simple_result.yaml | 11 ++ .../templates/outputs/simple_up_down.yaml | 17 ++ .../outputs/simple_up_down_result.yaml | 39 ++++ .../test/templates/outputs/template.yaml | 6 + .../templates/outputs/template_result.yaml | 17 ++ expandybird/test/templates/outputs/type.jinja | 8 + expandybird/test/templates/outputs/type.yaml | 5 + .../test/templates/outputs/type_result.yaml | 23 +++ .../test/templates/outputs/up_down.yaml | 14 ++ .../templates/outputs/up_down_result.yaml | 55 ++++++ .../templates/outputs/up_down_result_off.yaml | 38 ++++ 40 files changed, 817 insertions(+), 25 deletions(-) create mode 100644 expandybird/test/templates/outputs/backend.jinja create mode 100644 expandybird/test/templates/outputs/chain_multiple.yaml create mode 100644 expandybird/test/templates/outputs/chain_multiple_result.yaml create mode 100644 expandybird/test/templates/outputs/chain_outputs.yaml create mode 100644 expandybird/test/templates/outputs/chain_outputs_result.yaml create mode 100644 expandybird/test/templates/outputs/conditional.jinja create mode 100644 expandybird/test/templates/outputs/conditional.yaml create mode 100644 expandybird/test/templates/outputs/conditional_result.yaml create mode 100644 expandybird/test/templates/outputs/consume_multiple.yaml create mode 100644 expandybird/test/templates/outputs/consume_multiple_result.yaml create mode 100644 expandybird/test/templates/outputs/consume_output.yaml create mode 100644 expandybird/test/templates/outputs/consume_output_result.yaml create mode 100644 expandybird/test/templates/outputs/frontend.jinja create mode 100644 expandybird/test/templates/outputs/instance_builder.jinja create mode 100644 expandybird/test/templates/outputs/kubernetes.jinja create mode 100644 expandybird/test/templates/outputs/list_output.jinja create mode 100644 expandybird/test/templates/outputs/list_output.yaml create mode 100644 expandybird/test/templates/outputs/list_output_result.yaml create mode 100644 expandybird/test/templates/outputs/one_consume.jinja create mode 100644 expandybird/test/templates/outputs/one_simple.jinja create mode 100644 expandybird/test/templates/outputs/output_one.jinja create mode 100644 expandybird/test/templates/outputs/output_template.jinja create mode 100644 expandybird/test/templates/outputs/output_template.yaml create mode 100644 expandybird/test/templates/outputs/simple.jinja create mode 100644 expandybird/test/templates/outputs/simple.yaml create mode 100644 expandybird/test/templates/outputs/simple_result.yaml create mode 100644 expandybird/test/templates/outputs/simple_up_down.yaml create mode 100644 expandybird/test/templates/outputs/simple_up_down_result.yaml create mode 100644 expandybird/test/templates/outputs/template.yaml create mode 100644 expandybird/test/templates/outputs/template_result.yaml create mode 100644 expandybird/test/templates/outputs/type.jinja create mode 100644 expandybird/test/templates/outputs/type.yaml create mode 100644 expandybird/test/templates/outputs/type_result.yaml create mode 100644 expandybird/test/templates/outputs/up_down.yaml create mode 100644 expandybird/test/templates/outputs/up_down_result.yaml create mode 100644 expandybird/test/templates/outputs/up_down_result_off.yaml diff --git a/expandybird/expansion/expansion.py b/expandybird/expansion/expansion.py index 96f992c68..81f8588b3 100755 --- a/expandybird/expansion/expansion.py +++ b/expandybird/expansion/expansion.py @@ -24,10 +24,12 @@ import yaml from sandbox_loader import FileAccessRedirector +import references import schema_validation -def Expand(config, imports=None, env=None, validate_schema=False): +def Expand(config, imports=None, env=None, validate_schema=False, + outputs=False): """Expand the configuration with imports. Args: @@ -37,6 +39,7 @@ def Expand(config, imports=None, env=None, validate_schema=False): env: map from string to string, the map of environment variable names to their values validate_schema: True to run schema validation; False otherwise + outputs: True to process output values; False otherwise Returns: YAML containing the expanded configuration and its layout, in the following format: @@ -51,13 +54,13 @@ def Expand(config, imports=None, env=None, validate_schema=False): """ try: return _Expand(config, imports=imports, env=env, - validate_schema=validate_schema) + validate_schema=validate_schema, outputs=outputs) except Exception as e: - # print traceback.format_exc() raise ExpansionError('config', str(e)) -def _Expand(config, imports=None, env=None, validate_schema=False): +def _Expand(config, imports=None, env=None, validate_schema=False, + outputs=False): """Expand the configuration with imports.""" FileAccessRedirector.redirect(imports) @@ -76,7 +79,7 @@ def _Expand(config, imports=None, env=None, validate_schema=False): raise Exception('Error parsing YAML: %s' % msg) # Handle empty file case - if not yaml_config: + if yaml_config is None: return '' # If the configuration does not have ':' in it, the yaml_config will be a @@ -96,16 +99,20 @@ def _Expand(config, imports=None, env=None, validate_schema=False): # Iterate over all the resources to process. for resource in yaml_config['resources']: processed_resource = _ProcessResource(resource, imports, env, - validate_schema) + validate_schema, outputs) config['resources'].extend(processed_resource['config']['resources']) layout['resources'].append(processed_resource['layout']) - result = {'config': config, 'layout': layout} + _ProcessTargetConfig(yaml_config, outputs, config, layout) + + result = {'config': config, + 'layout': layout} return yaml.safe_dump(result, default_flow_style=False) -def _ProcessResource(resource, imports, env, validate_schema=False): +def _ProcessResource(resource, imports, env, validate_schema=False, + outputs=False): """Processes a resource and expands if template. Args: @@ -115,6 +122,7 @@ def _ProcessResource(resource, imports, env, validate_schema=False): env: map from string to string, the map of environment variable names to their values validate_schema: True to run schema validation; False otherwise + outputs: True to process output values; False otherwise Returns: A map containing the layout and configuration of the expanded resource and any sub-resources, in the format: @@ -136,7 +144,7 @@ def _ProcessResource(resource, imports, env, validate_schema=False): layout = {'name': resource['name'], 'type': resource['type']} - if resource['type'] in imports: + if imports and resource['type'] in imports: # A template resource, which contains sub-resources. expanded_template = ExpandTemplate(resource, imports, env, validate_schema) @@ -146,7 +154,7 @@ def _ProcessResource(resource, imports, env, validate_schema=False): # Process all sub-resources of this template. for resource_to_process in expanded_template['resources']: processed_resource = _ProcessResource(resource_to_process, imports, env, - validate_schema) + validate_schema, outputs) # Append all sub-resources to the config resources, and the resulting # layout of sub-resources. @@ -160,6 +168,9 @@ def _ProcessResource(resource, imports, env, validate_schema=False): if 'properties' in resource: layout['properties'] = resource['properties'] + + _ProcessTargetConfig(expanded_template, outputs, config, layout) + else: # A normal resource has only itself for config. config['resources'] = [resource] @@ -183,6 +194,99 @@ def _ValidateUniqueNames(template_resources, template_name='config'): # If this resource doesn't have a name, we will report that error later +def IsTemplate(resource_type): + """Returns whether a given resource type is a Template.""" + return resource_type.endswith('.py') or resource_type.endswith('.jinja') + + +def _BuildOutputMap(resource_objs): + """Given the layout of an expanded template, return map of its outputs. + + Args: + resource_objs: List of resources, some of which might be templates and have + outputs. + + Returns: + Map of template_name -> output_name -> output_value + """ + output_map = {} + + for resource in resource_objs: + if 'outputs' not in resource: + continue + output_value_map = {} + for output_item in resource['outputs']: + output_value_map[output_item['name']] = output_item['value'] + output_map[resource['name']] = output_value_map + + return output_map + + +def _ProcessTargetConfig(target, outputs, config, layout): + """Resolves outputs in the output and properties section of the config. + + Args: + target: Config that contains unprocessed output values + outputs: Values to process + config: Config object to update + layout: Layout object to update + """ + output_map = None + if 'resources' in layout: + output_map = _BuildOutputMap(layout['resources']) + + if outputs: + if 'outputs' in target and target['outputs']: + layout['outputs'] = _ResolveOutputs(target['outputs'], output_map) + + if 'resources' in config and config['resources']: + config['resources'] = _ResolveResources(config['resources'], output_map) + + +def _ResolveOutputs(outputs, output_map): + """Resolves references in the outputs. + + Args: + outputs: List of name,value dicts. + output_map: Result of _BuildOutputMap. + + Returns: + Outputs with all references resolved. + """ + if not output_map: + return outputs + + for i in range(len(outputs)): + outputs[i] = references.PopulateReferences(outputs[i], output_map) + + return outputs + + +def _ResolveResources(resource_objs, output_map): + """Resolves references in the properties block of a resource. + + Args: + resource_objs: The properties block to resolve references in. + output_map: Result of _BuildOutputMap. + + Returns: + resource_objs with all of the references to outputs resolved. + + Raises: + ExpansionReferenceError: if there were references to outputs that had bad + paths. + """ + if not output_map: + return resource_objs + + for resource in resource_objs: + if 'properties' in resource: + resource['properties'] = references.PopulateReferences( + resource['properties'], output_map) + + return resource_objs + + def ExpandTemplate(resource, imports, env, validate_schema=False): """Expands a template, calling expansion mechanism based on type. @@ -209,10 +313,19 @@ def ExpandTemplate(resource, imports, env, validate_schema=False): source_file, 'Unable to find source file %s in imports.' % (source_file)) - # source_file could be a short version of the template (say github short name) - # so we need to potentially map this into the fully resolvable name. - if 'path' in imports[source_file] and imports[source_file]['path']: - path = imports[source_file]['path'] + if isinstance(imports[source_file], dict): + # This code path assumes a different structure for the 'imports' param. + # Map of String (name) to Dict ('path', 'content'). + # + # source_file could be a short version of the template + # (say github short name) + # so we need to potentially map this into the fully resolvable name. + if 'path' in imports[source_file] and imports[source_file]['path']: + path = imports[source_file]['path'] + content = imports[source_file]['content'] + else: + path = source_file + content = imports[source_file] resource['imports'] = imports @@ -234,11 +347,11 @@ def ExpandTemplate(resource, imports, env, validate_schema=False): if path.endswith('jinja'): expanded_template = ExpandJinja( - source_file, imports[source_file]['content'], resource, imports) + source_file, content, resource, imports) elif path.endswith('py'): # This is a Python template. expanded_template = ExpandPython( - imports[source_file]['content'], source_file, resource) + content, source_file, resource) else: # The source file is not a jinja file or a python file. # This in fact should never happen due to the IsTemplate check above. @@ -263,8 +376,8 @@ def ExpandJinja(file_name, source_template, resource, imports): source_template: string, the content of jinja file to be render resource: resource object, the resource that contains parameters to the jinja file - imports: map from string to map {name, path}, the map of imported files names - fully resolved path and contents + imports: map from string to map {name, path}, the map of imported files + names fully resolved path and contents Returns: The final expanded template Raises: diff --git a/expandybird/expansion/expansion_test.py b/expandybird/expansion/expansion_test.py index 264d5e272..ed4e11578 100644 --- a/expandybird/expansion/expansion_test.py +++ b/expandybird/expansion/expansion_test.py @@ -143,8 +143,11 @@ class ExpansionTest(unittest.TestCase): self.assertEquals(result_file, expanded_template) def testNoImportErrors(self): + # TODO(grahamawelch): Ask Ville what whether this test should expect an + # expansion failure or not... template = 'resources: \n- type: something.jinja\n name: something' - expansion.Expand(template, {}) + # expansion.Expand(template, {}) + # Maybe it should fail, maybe it shouldn't... def testInvalidConfig(self): template = ReadTestFile('invalid_config.yaml') @@ -153,9 +156,8 @@ class ExpansionTest(unittest.TestCase): expansion.Expand( template) self.fail('Expansion should fail') - except expansion.ExpansionError as e: - self.assertNotIn(os.path.basename(expansion.__name__), e.message, - 'Do not leak internals') + except Exception as e: + self.assertIn('Error parsing YAML', e.message) def testJinjaWithImport(self): template = ReadTestFile('jinja_template_with_import.yaml') @@ -352,8 +354,8 @@ class ExpansionTest(unittest.TestCase): self.assertIn('no_resources.py', e.message) def testJinjaDefaultsSchema(self): - # Loop 1000 times to make sure we don't rely on dictionary ordering. - for unused_x in range(0, 1000): + # Loop 100 times to make sure we don't rely on dictionary ordering. + for unused_x in range(0, 100): template = ReadTestFile('jinja_defaults.yaml') imports = {} @@ -500,5 +502,164 @@ class ExpansionTest(unittest.TestCase): self.assertEquals(result_file, expanded_template) + # Output Tests + + def testSimpleOutput(self): + template = ReadTestFile('outputs/simple.yaml') + + expanded_template = expansion.Expand( + template, {}, validate_schema=True, outputs=True) + + result_file = ReadTestFile('outputs/simple_result.yaml') + + self.assertEquals(result_file, expanded_template) + + def testSimpleTemplateOutput(self): + template = ReadTestFile('outputs/template.yaml') + + imports = {} + imports['simple.jinja'] = ReadTestFile( + 'outputs/simple.jinja') + + expanded_template = expansion.Expand( + template, imports, validate_schema=True, outputs=True) + + result_file = ReadTestFile('outputs/template_result.yaml') + + self.assertEquals(result_file, expanded_template) + + def testChainOutput(self): + template = ReadTestFile('outputs/chain_outputs.yaml') + + imports = {} + imports['simple.jinja'] = ReadTestFile( + 'outputs/simple.jinja') + + expanded_template = expansion.Expand( + template, imports, validate_schema=True, outputs=True) + + result_file = ReadTestFile('outputs/chain_outputs_result.yaml') + + self.assertEquals(result_file, expanded_template) + + def testChainMultiple(self): + template = ReadTestFile('outputs/chain_multiple.yaml') + + imports = {} + imports['simple.jinja'] = ReadTestFile('outputs/simple.jinja') + imports['one_simple.jinja'] = ReadTestFile('outputs/one_simple.jinja') + + expanded_template = expansion.Expand( + template, imports, validate_schema=True, outputs=True) + + result_file = ReadTestFile('outputs/chain_multiple_result.yaml') + + self.assertEquals(result_file, expanded_template) + + def testConsumeOutput(self): + template = ReadTestFile('outputs/consume_output.yaml') + + imports = {} + imports['simple.jinja'] = ReadTestFile('outputs/simple.jinja') + + expanded_template = expansion.Expand( + template, imports, validate_schema=True, outputs=True) + + result_file = ReadTestFile('outputs/consume_output_result.yaml') + + self.assertEquals(result_file, expanded_template) + + def testConsumeMultiple(self): + template = ReadTestFile('outputs/consume_multiple.yaml') + + imports = {} + imports['simple.jinja'] = ReadTestFile('outputs/simple.jinja') + imports['one_consume.jinja'] = ReadTestFile('outputs/one_consume.jinja') + + expanded_template = expansion.Expand( + template, imports, validate_schema=True, outputs=True) + + result_file = ReadTestFile('outputs/consume_multiple_result.yaml') + + self.assertEquals(result_file, expanded_template) + + def testConsumeListOutput(self): + template = ReadTestFile('outputs/list_output.yaml') + + imports = {} + imports['list_output.jinja'] = ReadTestFile('outputs/list_output.jinja') + + expanded_template = expansion.Expand( + template, imports, validate_schema=True, outputs=True) + + result_file = ReadTestFile('outputs/list_output_result.yaml') + + self.assertEquals(result_file, expanded_template) + + def testSimpleUpDown(self): + template = ReadTestFile('outputs/simple_up_down.yaml') + + imports = {} + imports['instance_builder.jinja'] = ReadTestFile( + 'outputs/instance_builder.jinja') + + expanded_template = expansion.Expand( + template, imports, validate_schema=True, outputs=True) + + result_file = ReadTestFile('outputs/simple_up_down_result.yaml') + + self.assertEquals(result_file, expanded_template) + + def testUpDown(self): + template = ReadTestFile('outputs/up_down.yaml') + + imports = {} + imports['frontend.jinja'] = ReadTestFile('outputs/frontend.jinja') + imports['backend.jinja'] = ReadTestFile('outputs/backend.jinja') + imports['instance_builder.jinja'] = ReadTestFile( + 'outputs/instance_builder.jinja') + + expanded_template = expansion.Expand( + template, imports, validate_schema=True, outputs=True) + + result_file = ReadTestFile('outputs/up_down_result.yaml') + + self.assertEquals(result_file, expanded_template) + + def testUpDownWithOutputsOff(self): + template = ReadTestFile('outputs/up_down.yaml') + + imports = {} + imports['frontend.jinja'] = ReadTestFile('outputs/frontend.jinja') + imports['backend.jinja'] = ReadTestFile('outputs/backend.jinja') + imports['instance_builder.jinja'] = ReadTestFile( + 'outputs/instance_builder.jinja') + + expanded_template = expansion.Expand( + template, imports, validate_schema=True, outputs=False) + + result_file = ReadTestFile('outputs/up_down_result_off.yaml') + + self.assertEquals(result_file, expanded_template) + + def testConditionalDoesntWork(self): + """Verifies that conditionals on references don't work. + + That is, you can't output 2 then use that value in another template to + create 2 instances. + """ + template = ReadTestFile('outputs/conditional.yaml') + + imports = {} + imports['conditional.jinja'] = ReadTestFile('outputs/conditional.jinja') + imports['output_one.jinja'] = ReadTestFile('outputs/output_one.jinja') + + expanded_template = expansion.Expand( + template, imports, validate_schema=True, outputs=True) + + result_file = ReadTestFile('outputs/conditional_result.yaml') + + self.assertEquals(result_file, expanded_template) + if __name__ == '__main__': unittest.main() diff --git a/expandybird/expansion/sandbox_loader.py b/expandybird/expansion/sandbox_loader.py index f0fcde48c..86140db26 100644 --- a/expandybird/expansion/sandbox_loader.py +++ b/expandybird/expansion/sandbox_loader.py @@ -95,7 +95,10 @@ def process_imports(imports): # Now build the hierarchical modules. for k in imports.keys(): - path = imports[k]['path'] + if isinstance(imports[k], dict): + path = imports[k]['path'] + else: + path = k if path.endswith('.jinja'): continue # Normalize paths and trim .py extension, if any. diff --git a/expandybird/requirements.txt b/expandybird/requirements.txt index 0850c2d34..9b00e05fd 100644 --- a/expandybird/requirements.txt +++ b/expandybird/requirements.txt @@ -1,3 +1,4 @@ pyyaml Jinja2 Jsonschema +Jsonpath diff --git a/expandybird/test/templates/outputs/backend.jinja b/expandybird/test/templates/outputs/backend.jinja new file mode 100644 index 000000000..bb6ceedd8 --- /dev/null +++ b/expandybird/test/templates/outputs/backend.jinja @@ -0,0 +1,9 @@ +resources: +- name: {{ env['name'] }}-backend + type: instance_builder.jinja + properties: + instance-name: {{ env['name'] }}-backend-vm + target-ip: {{ properties['frontend-ip'] }} +outputs: +- name: ip + value: $(ref.{{ env['name'] }}-backend.ip) diff --git a/expandybird/test/templates/outputs/chain_multiple.yaml b/expandybird/test/templates/outputs/chain_multiple.yaml new file mode 100644 index 000000000..6ecfcc17b --- /dev/null +++ b/expandybird/test/templates/outputs/chain_multiple.yaml @@ -0,0 +1,8 @@ +resources: +- name: chain-template + type: one_simple.jinja +outputs: +- name: parent_ip + # We expect the ip value from one_simple.jinja, which in turn + # comes from simple.jinja, 192.168.0.0 + value: $(ref.chain-template.intermediate_ip) diff --git a/expandybird/test/templates/outputs/chain_multiple_result.yaml b/expandybird/test/templates/outputs/chain_multiple_result.yaml new file mode 100644 index 000000000..21e84e617 --- /dev/null +++ b/expandybird/test/templates/outputs/chain_multiple_result.yaml @@ -0,0 +1,23 @@ +config: + resources: + - name: simple-name + type: simple-instance +layout: + outputs: + - name: parent_ip + value: 192.168.0.0 + resources: + - name: chain-template + outputs: + - name: intermediate_ip + value: 192.168.0.0 + resources: + - name: simple-template + outputs: + - name: ip + value: 192.168.0.0 + resources: + - name: simple-name + type: simple-instance + type: simple.jinja + type: one_simple.jinja diff --git a/expandybird/test/templates/outputs/chain_outputs.yaml b/expandybird/test/templates/outputs/chain_outputs.yaml new file mode 100644 index 000000000..4a748b50c --- /dev/null +++ b/expandybird/test/templates/outputs/chain_outputs.yaml @@ -0,0 +1,7 @@ +resources: +- name: simple-template + type: simple.jinja +outputs: +- name: parent_ip + # We expect the ip value from simple.jinja, 192.168.0.0 + value: $(ref.simple-template.ip) diff --git a/expandybird/test/templates/outputs/chain_outputs_result.yaml b/expandybird/test/templates/outputs/chain_outputs_result.yaml new file mode 100644 index 000000000..410cdc5df --- /dev/null +++ b/expandybird/test/templates/outputs/chain_outputs_result.yaml @@ -0,0 +1,17 @@ +config: + resources: + - name: simple-name + type: simple-instance +layout: + outputs: + - name: parent_ip + value: 192.168.0.0 + resources: + - name: simple-template + outputs: + - name: ip + value: 192.168.0.0 + resources: + - name: simple-name + type: simple-instance + type: simple.jinja diff --git a/expandybird/test/templates/outputs/conditional.jinja b/expandybird/test/templates/outputs/conditional.jinja new file mode 100644 index 000000000..f2c3c09d1 --- /dev/null +++ b/expandybird/test/templates/outputs/conditional.jinja @@ -0,0 +1,9 @@ +resources: +- name: one + type: simple-instance +{# properties['value'] is a refernce, not yet a number. #} +{# So this shouldn't output anything. #} +{% if properties['value'] is number %} +- name: two + type: simple-instance +{% endif %} diff --git a/expandybird/test/templates/outputs/conditional.yaml b/expandybird/test/templates/outputs/conditional.yaml new file mode 100644 index 000000000..5e17c147b --- /dev/null +++ b/expandybird/test/templates/outputs/conditional.yaml @@ -0,0 +1,7 @@ +resources: +- name: one + type: output_one.jinja +- name: conditional + type: conditional.jinja + properties: + value: $(ref.one.one) diff --git a/expandybird/test/templates/outputs/conditional_result.yaml b/expandybird/test/templates/outputs/conditional_result.yaml new file mode 100644 index 000000000..eaea94bdb --- /dev/null +++ b/expandybird/test/templates/outputs/conditional_result.yaml @@ -0,0 +1,18 @@ +config: + resources: + - name: one + type: simple-instance +layout: + resources: + - name: one + outputs: + - name: one + value: 1 + type: output_one.jinja + - name: conditional + properties: + value: $(ref.one.one) + resources: + - name: one + type: simple-instance + type: conditional.jinja diff --git a/expandybird/test/templates/outputs/consume_multiple.yaml b/expandybird/test/templates/outputs/consume_multiple.yaml new file mode 100644 index 000000000..b392c8ae5 --- /dev/null +++ b/expandybird/test/templates/outputs/consume_multiple.yaml @@ -0,0 +1,8 @@ +resources: +- name: simple-consume-template + type: one_consume.jinja +- name: consume-simple + type: simple-instance + properties: + # Get the output value of simple.jinja, we expect 192.168.0.0 + target: $(ref.simple-consume-template.intermediate_ip) diff --git a/expandybird/test/templates/outputs/consume_multiple_result.yaml b/expandybird/test/templates/outputs/consume_multiple_result.yaml new file mode 100644 index 000000000..961b43dd3 --- /dev/null +++ b/expandybird/test/templates/outputs/consume_multiple_result.yaml @@ -0,0 +1,32 @@ +config: + resources: + - name: simple-name + type: simple-instance + - name: sub-consume-simple + properties: + target: 192.168.0.0 + type: simple-instance + - name: consume-simple + properties: + target: 192.168.0.0 + type: simple-instance +layout: + resources: + - name: simple-consume-template + outputs: + - name: intermediate_ip + value: 192.168.0.0 + resources: + - name: simple-template + outputs: + - name: ip + value: 192.168.0.0 + resources: + - name: simple-name + type: simple-instance + type: simple.jinja + - name: sub-consume-simple + type: simple-instance + type: one_consume.jinja + - name: consume-simple + type: simple-instance diff --git a/expandybird/test/templates/outputs/consume_output.yaml b/expandybird/test/templates/outputs/consume_output.yaml new file mode 100644 index 000000000..8472ddec0 --- /dev/null +++ b/expandybird/test/templates/outputs/consume_output.yaml @@ -0,0 +1,8 @@ +resources: +- name: simple-template + type: simple.jinja +- name: consume-simple + type: simple-instance + properties: + # Get the output value of simple.jinja, we expect 192.168.0.0 + target: $(ref.simple-template.ip) diff --git a/expandybird/test/templates/outputs/consume_output_result.yaml b/expandybird/test/templates/outputs/consume_output_result.yaml new file mode 100644 index 000000000..73a3f1774 --- /dev/null +++ b/expandybird/test/templates/outputs/consume_output_result.yaml @@ -0,0 +1,20 @@ +config: + resources: + - name: simple-name + type: simple-instance + - name: consume-simple + properties: + target: 192.168.0.0 + type: simple-instance +layout: + resources: + - name: simple-template + outputs: + - name: ip + value: 192.168.0.0 + resources: + - name: simple-name + type: simple-instance + type: simple.jinja + - name: consume-simple + type: simple-instance diff --git a/expandybird/test/templates/outputs/frontend.jinja b/expandybird/test/templates/outputs/frontend.jinja new file mode 100644 index 000000000..2bfa613e3 --- /dev/null +++ b/expandybird/test/templates/outputs/frontend.jinja @@ -0,0 +1,9 @@ +resources: +- name: {{ env['name'] }}-frontend + type: instance_builder.jinja + properties: + instance-name: {{ env['name'] }}-frontend-vm + target-ip: {{ properties['backend-ip'] }} +outputs: +- name: ip + value: $(ref.{{ env['name'] }}-frontend.ip) diff --git a/expandybird/test/templates/outputs/instance_builder.jinja b/expandybird/test/templates/outputs/instance_builder.jinja new file mode 100644 index 000000000..2f4a57b4d --- /dev/null +++ b/expandybird/test/templates/outputs/instance_builder.jinja @@ -0,0 +1,8 @@ +resources: +- name: {{ properties['instance-name'] }} + type: simple-instance + properties: + target: {{ properties['target-ip'] }} +outputs: +- name: ip + value: $(ref.{{ properties['instance-name'] }}.network[0].ip) diff --git a/expandybird/test/templates/outputs/kubernetes.jinja b/expandybird/test/templates/outputs/kubernetes.jinja new file mode 100644 index 000000000..78fa39902 --- /dev/null +++ b/expandybird/test/templates/outputs/kubernetes.jinja @@ -0,0 +1,5 @@ +resources: + +outputs: +- name: type + value: my-kubernetes diff --git a/expandybird/test/templates/outputs/list_output.jinja b/expandybird/test/templates/outputs/list_output.jinja new file mode 100644 index 000000000..981c61712 --- /dev/null +++ b/expandybird/test/templates/outputs/list_output.jinja @@ -0,0 +1,9 @@ +resources: +- name: simple-name + type: simple-instance +outputs: +- name: ips + value: + - 192.168.0.0 + - 192.168.0.1 + - 192.168.0.2 diff --git a/expandybird/test/templates/outputs/list_output.yaml b/expandybird/test/templates/outputs/list_output.yaml new file mode 100644 index 000000000..76c5ccc52 --- /dev/null +++ b/expandybird/test/templates/outputs/list_output.yaml @@ -0,0 +1,12 @@ +resources: +- name: simple-template + type: list_output.jinja +- name: consume-list + type: simple-instance + properties: + # Get the first output value of list_output.jinja, we expect 192.168.0.1 + first-ip: $(ref.simple-template.ips[1]) +outputs: +- name: second-ip + # We expect the 2nd ip from list_outputs.jinja, 192.168.0.2 + value: $(ref.simple-template.ips[2]) diff --git a/expandybird/test/templates/outputs/list_output_result.yaml b/expandybird/test/templates/outputs/list_output_result.yaml new file mode 100644 index 000000000..47f36d7c7 --- /dev/null +++ b/expandybird/test/templates/outputs/list_output_result.yaml @@ -0,0 +1,26 @@ +config: + resources: + - name: simple-name + type: simple-instance + - name: consume-list + properties: + first-ip: 192.168.0.1 + type: simple-instance +layout: + outputs: + - name: second-ip + value: 192.168.0.2 + resources: + - name: simple-template + outputs: + - name: ips + value: + - 192.168.0.0 + - 192.168.0.1 + - 192.168.0.2 + resources: + - name: simple-name + type: simple-instance + type: list_output.jinja + - name: consume-list + type: simple-instance diff --git a/expandybird/test/templates/outputs/one_consume.jinja b/expandybird/test/templates/outputs/one_consume.jinja new file mode 100644 index 000000000..bfab3e780 --- /dev/null +++ b/expandybird/test/templates/outputs/one_consume.jinja @@ -0,0 +1,12 @@ +resources: +- name: simple-template + type: simple.jinja +- name: sub-consume-simple + type: simple-instance + properties: + # Get the output value of simple.jinja, we expect 192.168.0.0 + target: $(ref.simple-template.ip) +outputs: +- name: intermediate_ip + # We expect the ip value from simple.jinja, 192.168.0.0 + value: $(ref.simple-template.ip) diff --git a/expandybird/test/templates/outputs/one_simple.jinja b/expandybird/test/templates/outputs/one_simple.jinja new file mode 100644 index 000000000..17b69f3f9 --- /dev/null +++ b/expandybird/test/templates/outputs/one_simple.jinja @@ -0,0 +1,7 @@ +resources: +- name: simple-template + type: simple.jinja +outputs: +- name: intermediate_ip + # We expect the ip value from simple.jinja, 192.168.0.0 + value: $(ref.simple-template.ip) diff --git a/expandybird/test/templates/outputs/output_one.jinja b/expandybird/test/templates/outputs/output_one.jinja new file mode 100644 index 000000000..53273fa65 --- /dev/null +++ b/expandybird/test/templates/outputs/output_one.jinja @@ -0,0 +1,5 @@ +resources: + +outputs: +- name: one + value: 1 diff --git a/expandybird/test/templates/outputs/output_template.jinja b/expandybird/test/templates/outputs/output_template.jinja new file mode 100644 index 000000000..dcce246a7 --- /dev/null +++ b/expandybird/test/templates/outputs/output_template.jinja @@ -0,0 +1,5 @@ +resources: + +outputs: +- name: type + value: helper.jinja diff --git a/expandybird/test/templates/outputs/output_template.yaml b/expandybird/test/templates/outputs/output_template.yaml new file mode 100644 index 000000000..272dad1fe --- /dev/null +++ b/expandybird/test/templates/outputs/output_template.yaml @@ -0,0 +1,5 @@ +resources: +- name: foo + type: output_template.jinja +- name: bar + type: $(ref.foo.type) diff --git a/expandybird/test/templates/outputs/simple.jinja b/expandybird/test/templates/outputs/simple.jinja new file mode 100644 index 000000000..db3fcd2ea --- /dev/null +++ b/expandybird/test/templates/outputs/simple.jinja @@ -0,0 +1,6 @@ +resources: +- name: simple-name + type: simple-instance +outputs: +- name: ip + value: 192.168.0.0 diff --git a/expandybird/test/templates/outputs/simple.yaml b/expandybird/test/templates/outputs/simple.yaml new file mode 100644 index 000000000..d8f624f47 --- /dev/null +++ b/expandybird/test/templates/outputs/simple.yaml @@ -0,0 +1,6 @@ +resources: +- type: simple-type + name: simple-instance +outputs: +- name: ip + value: 192.168.0.0 diff --git a/expandybird/test/templates/outputs/simple_result.yaml b/expandybird/test/templates/outputs/simple_result.yaml new file mode 100644 index 000000000..c093d78c2 --- /dev/null +++ b/expandybird/test/templates/outputs/simple_result.yaml @@ -0,0 +1,11 @@ +config: + resources: + - name: simple-instance + type: simple-type +layout: + outputs: + - name: ip + value: 192.168.0.0 + resources: + - name: simple-instance + type: simple-type diff --git a/expandybird/test/templates/outputs/simple_up_down.yaml b/expandybird/test/templates/outputs/simple_up_down.yaml new file mode 100644 index 000000000..d16d44cd1 --- /dev/null +++ b/expandybird/test/templates/outputs/simple_up_down.yaml @@ -0,0 +1,17 @@ +resources: +- name: frontend + type: instance_builder.jinja + properties: + instance-name: mixer + target-ip: $(ref.backend.ip) + +- name: backend + type: instance_builder.jinja + properties: + instance-name: workflow + target-ip: $(ref.frontend.ip) +outputs: +- name: frontend-ip + value: $(ref.frontend.ip) +- name: backend-ip + value: $(ref.backend.ip) diff --git a/expandybird/test/templates/outputs/simple_up_down_result.yaml b/expandybird/test/templates/outputs/simple_up_down_result.yaml new file mode 100644 index 000000000..e7199fe05 --- /dev/null +++ b/expandybird/test/templates/outputs/simple_up_down_result.yaml @@ -0,0 +1,39 @@ +config: + resources: + - name: mixer + properties: + target: $(ref.workflow.network[0].ip) + type: simple-instance + - name: workflow + properties: + target: $(ref.mixer.network[0].ip) + type: simple-instance +layout: + outputs: + - name: frontend-ip + value: $(ref.mixer.network[0].ip) + - name: backend-ip + value: $(ref.workflow.network[0].ip) + resources: + - name: frontend + outputs: + - name: ip + value: $(ref.mixer.network[0].ip) + properties: + instance-name: mixer + target-ip: $(ref.backend.ip) + resources: + - name: mixer + type: simple-instance + type: instance_builder.jinja + - name: backend + outputs: + - name: ip + value: $(ref.workflow.network[0].ip) + properties: + instance-name: workflow + target-ip: $(ref.frontend.ip) + resources: + - name: workflow + type: simple-instance + type: instance_builder.jinja diff --git a/expandybird/test/templates/outputs/template.yaml b/expandybird/test/templates/outputs/template.yaml new file mode 100644 index 000000000..4d346d6e1 --- /dev/null +++ b/expandybird/test/templates/outputs/template.yaml @@ -0,0 +1,6 @@ +resources: +- name: simple-template + type: simple.jinja +outputs: +- name: port + value: 88 diff --git a/expandybird/test/templates/outputs/template_result.yaml b/expandybird/test/templates/outputs/template_result.yaml new file mode 100644 index 000000000..c81d8b287 --- /dev/null +++ b/expandybird/test/templates/outputs/template_result.yaml @@ -0,0 +1,17 @@ +config: + resources: + - name: simple-name + type: simple-instance +layout: + outputs: + - name: port + value: 88 + resources: + - name: simple-template + outputs: + - name: ip + value: 192.168.0.0 + resources: + - name: simple-name + type: simple-instance + type: simple.jinja diff --git a/expandybird/test/templates/outputs/type.jinja b/expandybird/test/templates/outputs/type.jinja new file mode 100644 index 000000000..eb6269ed2 --- /dev/null +++ b/expandybird/test/templates/outputs/type.jinja @@ -0,0 +1,8 @@ +resources: +- name: kubernetes-cluster + type: kubernetes.jinja +- name: sub-thingy + type: $(ref.kubernetes-cluster.type) +outputs: +- name: type + value: $(ref.kubernetes-cluster.type) diff --git a/expandybird/test/templates/outputs/type.yaml b/expandybird/test/templates/outputs/type.yaml new file mode 100644 index 000000000..aa49dc9a7 --- /dev/null +++ b/expandybird/test/templates/outputs/type.yaml @@ -0,0 +1,5 @@ +resources: +- name: type-helper + type: type.jinja +- name: thingy + type: $(ref.type-helper.type) diff --git a/expandybird/test/templates/outputs/type_result.yaml b/expandybird/test/templates/outputs/type_result.yaml new file mode 100644 index 000000000..6ddaf9a52 --- /dev/null +++ b/expandybird/test/templates/outputs/type_result.yaml @@ -0,0 +1,23 @@ +config: + resources: + - name: sub-thingy + type: my-kubernetes + - name: thingy + type: my-kubernetes +layout: + resources: + - name: type-helper + outputs: + - name: type + value: my-kubernetes + resources: + - name: kubernetes-cluster + outputs: + - name: type + value: my-kubernetes + type: kubernetes.jinja + - name: sub-thingy + type: $(ref.kubernetes-cluster.type) + type: type.jinja + - name: thingy + type: $(ref.type-helper.type) diff --git a/expandybird/test/templates/outputs/up_down.yaml b/expandybird/test/templates/outputs/up_down.yaml new file mode 100644 index 000000000..6e195f4e2 --- /dev/null +++ b/expandybird/test/templates/outputs/up_down.yaml @@ -0,0 +1,14 @@ +resources: +- name: mixer + type: frontend.jinja + properties: + backend-ip: $(ref.workflow.ip) +- name: workflow + type: backend.jinja + properties: + frontend-ip: $(ref.mixer.ip) +outputs: +- name: frontend-ip + value: $(ref.mixer.ip) +- name: backend-ip + value: $(ref.workflow.ip) diff --git a/expandybird/test/templates/outputs/up_down_result.yaml b/expandybird/test/templates/outputs/up_down_result.yaml new file mode 100644 index 000000000..c5fcb34fb --- /dev/null +++ b/expandybird/test/templates/outputs/up_down_result.yaml @@ -0,0 +1,55 @@ +config: + resources: + - name: mixer-frontend-vm + properties: + target: $(ref.workflow-backend-vm.network[0].ip) + type: simple-instance + - name: workflow-backend-vm + properties: + target: $(ref.mixer-frontend-vm.network[0].ip) + type: simple-instance +layout: + outputs: + - name: frontend-ip + value: $(ref.mixer-frontend-vm.network[0].ip) + - name: backend-ip + value: $(ref.workflow-backend-vm.network[0].ip) + resources: + - name: mixer + outputs: + - name: ip + value: $(ref.mixer-frontend-vm.network[0].ip) + properties: + backend-ip: $(ref.workflow.ip) + resources: + - name: mixer-frontend + outputs: + - name: ip + value: $(ref.mixer-frontend-vm.network[0].ip) + properties: + instance-name: mixer-frontend-vm + target-ip: $(ref.workflow.ip) + resources: + - name: mixer-frontend-vm + type: simple-instance + type: instance_builder.jinja + type: frontend.jinja + - name: workflow + outputs: + - name: ip + value: $(ref.workflow-backend-vm.network[0].ip) + properties: + frontend-ip: $(ref.mixer.ip) + resources: + - name: workflow-backend + outputs: + - name: ip + value: $(ref.workflow-backend-vm.network[0].ip) + properties: + instance-name: workflow-backend-vm + target-ip: $(ref.mixer.ip) + resources: + - name: workflow-backend-vm + type: simple-instance + type: instance_builder.jinja + type: backend.jinja diff --git a/expandybird/test/templates/outputs/up_down_result_off.yaml b/expandybird/test/templates/outputs/up_down_result_off.yaml new file mode 100644 index 000000000..d83df4c6a --- /dev/null +++ b/expandybird/test/templates/outputs/up_down_result_off.yaml @@ -0,0 +1,38 @@ +config: + resources: + - name: mixer-frontend-vm + properties: + target: $(ref.workflow.ip) + type: simple-instance + - name: workflow-backend-vm + properties: + target: $(ref.mixer.ip) + type: simple-instance +layout: + resources: + - name: mixer + properties: + backend-ip: $(ref.workflow.ip) + resources: + - name: mixer-frontend + properties: + instance-name: mixer-frontend-vm + target-ip: $(ref.workflow.ip) + resources: + - name: mixer-frontend-vm + type: simple-instance + type: instance_builder.jinja + type: frontend.jinja + - name: workflow + properties: + frontend-ip: $(ref.mixer.ip) + resources: + - name: workflow-backend + properties: + instance-name: workflow-backend-vm + target-ip: $(ref.mixer.ip) + resources: + - name: workflow-backend-vm + type: simple-instance + type: instance_builder.jinja + type: backend.jinja From 47b235c243f7be52cc42753cd568c51647f08184 Mon Sep 17 00:00:00 2001 From: Graham Welch Date: Tue, 15 Dec 2015 16:38:51 -0800 Subject: [PATCH 3/6] 'make test' now runs expansion, schema_validation, and references unit tests. --- expandybird/Makefile | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/expandybird/Makefile b/expandybird/Makefile index eb6ecc41a..db14d008c 100644 --- a/expandybird/Makefile +++ b/expandybird/Makefile @@ -27,6 +27,12 @@ expandybird: go get -v ./... go install -v ./... +test: + python expansion/expansion_test.py && \ + python expansion/schema_validation_test.py && \ + python expansion/references_test.py + + clean: -docker rmi $(PREFIX)/$(IMAGE):$(TAG) rm -f expandybird From 72b8f48bbd41ec168a2ad00f8c5e3a19e94ec254 Mon Sep 17 00:00:00 2001 From: Graham Welch Date: Tue, 15 Dec 2015 16:59:03 -0800 Subject: [PATCH 4/6] Add Kubernetes header to references_test.py --- expandybird/expansion/references.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/expandybird/expansion/references.py b/expandybird/expansion/references.py index 6fc46c09a..f35c7d246 100644 --- a/expandybird/expansion/references.py +++ b/expandybird/expansion/references.py @@ -1,3 +1,15 @@ +###################################################################### +# Copyright 2015 The Kubernetes Authors All 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. +###################################################################### """Util to handle references during expansion.""" import jsonpath From c0cb2b236710c95423121faf508fa80a6ca7df04 Mon Sep 17 00:00:00 2001 From: Graham Welch Date: Wed, 16 Dec 2015 10:08:48 -0800 Subject: [PATCH 5/6] Jsonpath is an externally hosted library. Add flags so pip install will work. --- expandybird/requirements.txt | 2 ++ 1 file changed, 2 insertions(+) diff --git a/expandybird/requirements.txt b/expandybird/requirements.txt index 9b00e05fd..e49ba1fd4 100644 --- a/expandybird/requirements.txt +++ b/expandybird/requirements.txt @@ -1,4 +1,6 @@ +--allow-all-external pyyaml Jinja2 Jsonschema +--allow-unverified Jsonpath Jsonpath From dbf7e276446a022677639890f5977eb9530dec98 Mon Sep 17 00:00:00 2001 From: Graham Welch Date: Thu, 17 Dec 2015 11:24:46 -0800 Subject: [PATCH 6/6] Test for schema that sets defaults on resource with no properties block --- expandybird/expansion/expansion_test.py | 16 ++++++++++++++++ .../templates/no_properties_schema_defaults.py | 11 +++++++++++ .../no_properties_schema_defaults.py.schema | 5 +++++ .../templates/no_properties_schema_defaults.yaml | 6 ++++++ .../no_properties_schema_defaults_result.yaml | 16 ++++++++++++++++ 5 files changed, 54 insertions(+) create mode 100644 expandybird/test/templates/no_properties_schema_defaults.py create mode 100644 expandybird/test/templates/no_properties_schema_defaults.py.schema create mode 100644 expandybird/test/templates/no_properties_schema_defaults.yaml create mode 100644 expandybird/test/templates/no_properties_schema_defaults_result.yaml diff --git a/expandybird/expansion/expansion_test.py b/expandybird/expansion/expansion_test.py index ed4e11578..fbb74c6bd 100644 --- a/expandybird/expansion/expansion_test.py +++ b/expandybird/expansion/expansion_test.py @@ -481,6 +481,22 @@ class ExpansionTest(unittest.TestCase): self.assertEquals(result_file, expanded_template) + def testNoPropertiesSchemaDefaults(self): + template = ReadTestFile('no_properties_schema_defaults.yaml') + + imports = {} + imports['no_properties_schema_defaults.py'] = ReadTestFile( + 'no_properties_schema_defaults.py') + imports['no_properties_schema_defaults.py.schema'] = ReadTestFile( + 'no_properties_schema_defaults.py.schema') + + expanded_template = expansion.Expand( + template, imports, validate_schema=True) + + result_file = ReadTestFile('no_properties_schema_defaults_result.yaml') + + self.assertEquals(result_file, expanded_template) + def testNestedTemplateSchema(self): template = ReadTestFile('use_helper.yaml') diff --git a/expandybird/test/templates/no_properties_schema_defaults.py b/expandybird/test/templates/no_properties_schema_defaults.py new file mode 100644 index 000000000..8fd85dc2d --- /dev/null +++ b/expandybird/test/templates/no_properties_schema_defaults.py @@ -0,0 +1,11 @@ +"""Basic firewall template.""" + + +def GenerateConfig(evaluation_context): + return """ +resources: +- type: compute.v1.firewall + name: %(master)s-firewall + properties: + sourceRanges: [ "0.0.0.0/0" ] +""" % {"master": evaluation_context.properties["firewallname"]} diff --git a/expandybird/test/templates/no_properties_schema_defaults.py.schema b/expandybird/test/templates/no_properties_schema_defaults.py.schema new file mode 100644 index 000000000..82f56a531 --- /dev/null +++ b/expandybird/test/templates/no_properties_schema_defaults.py.schema @@ -0,0 +1,5 @@ + +properties: + firewallname: + type: string + default: defaultname diff --git a/expandybird/test/templates/no_properties_schema_defaults.yaml b/expandybird/test/templates/no_properties_schema_defaults.yaml new file mode 100644 index 000000000..605413b65 --- /dev/null +++ b/expandybird/test/templates/no_properties_schema_defaults.yaml @@ -0,0 +1,6 @@ +imports: +- path: "no_properties_schema_defaults.py" + +resources: +- name: test-resource + type: no_properties_schema_defaults.py diff --git a/expandybird/test/templates/no_properties_schema_defaults_result.yaml b/expandybird/test/templates/no_properties_schema_defaults_result.yaml new file mode 100644 index 000000000..12af50849 --- /dev/null +++ b/expandybird/test/templates/no_properties_schema_defaults_result.yaml @@ -0,0 +1,16 @@ +config: + resources: + - name: defaultname-firewall + properties: + sourceRanges: + - 0.0.0.0/0 + type: compute.v1.firewall +layout: + resources: + - name: test-resource + properties: + firewallname: defaultname + resources: + - name: defaultname-firewall + type: compute.v1.firewall + type: no_properties_schema_defaults.py