From c8a662b49963f07810942f6d39ce6e6f6fdd4adc Mon Sep 17 00:00:00 2001 From: Marc Cunningham Date: Mon, 3 Jun 2024 12:11:15 -0700 Subject: [PATCH 1/3] Implement canary file generation functionality from contract test input for Patch Canaries --- src/rpdk/core/project.py | 75 ++++-- tests/test_project.py | 493 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 552 insertions(+), 16 deletions(-) diff --git a/src/rpdk/core/project.py b/src/rpdk/core/project.py index 0c907a5d..9205816d 100644 --- a/src/rpdk/core/project.py +++ b/src/rpdk/core/project.py @@ -1,4 +1,5 @@ # pylint: disable=too-many-lines +import copy import json import logging import os @@ -76,7 +77,6 @@ FILE_GENERATION_ENABLED = "file_generation_enabled" TYPE_NAME = "typeName" CONTRACT_TEST_FILE_NAMES = "contract_test_file_names" -INPUT1_FILE_NAME = "inputs_1.json" FN_SUB = "Fn::Sub" FN_IMPORT_VALUE = "Fn::ImportValue" UUID = "uuid" @@ -1345,21 +1345,64 @@ def _generate_stack_template_files(self) -> None: with ct_file.open("r") as f: json_data = json.load(f) resource_name = self.type_info[2] - stack_template_data = { - "Description": f"Template for {self.type_name}", - "Resources": { - f"{resource_name}": { - "Type": self.type_name, - "Properties": self._replace_dynamic_values( - json_data["CreateInputs"] - ), - } - }, - } - stack_template_file_name = f"{CANARY_FILE_PREFIX}{count}_001.yaml" - stack_template_file_path = stack_template_folder / stack_template_file_name - with stack_template_file_path.open("w") as stack_template_file: - yaml.dump(stack_template_data, stack_template_file, indent=2) + + if "PatchInputs" in json_data: + deepcopy_create_input = copy.deepcopy(json_data["CreateInputs"]) + self._save_stack_template_data( + resource_name, + count, + stack_template_folder, + self._apply_patch_inputs_to_create_inputs( + json_data["PatchInputs"], deepcopy_create_input + ), + "002", + ) + self._save_stack_template_data( + resource_name, + count, + stack_template_folder, + json_data["CreateInputs"], + "001", + ) + + def _save_stack_template_data( + self, + resource_name, + contract_test_input_count, + stack_template_folder, + properties_data, + suffix, + ): + stack_template_data = { + "Description": f"Template for {self.type_name}", + "Resources": { + f"{resource_name}": { + "Type": self.type_name, + "Properties": self._replace_dynamic_values( + properties_data, + ), + } + }, + } + stack_template_file_name = ( + f"{CANARY_FILE_PREFIX}{contract_test_input_count}_{suffix}.yaml" + ) + stack_template_file_path = stack_template_folder / stack_template_file_name + with stack_template_file_path.open("w") as stack_template_file: + yaml.dump(stack_template_data, stack_template_file, indent=2) + + def _apply_patch_inputs_to_create_inputs( + self, patch_inputs: Dict[str, Any], create_inputs: Dict[str, Any] + ) -> Dict[str, Any]: + output = create_inputs + for patch_input in patch_inputs: + if patch_input["op"] == "replace": + key_list = patch_input["path"].split("/") + current_output = output + for key in key_list[:-1]: + current_output = current_output.setdefault(key, {}) + current_output[key_list[-1]] = patch_input["value"] + return output def _replace_dynamic_values(self, properties: Dict[str, Any]) -> Dict[str, Any]: for key, value in properties.items(): diff --git a/tests/test_project.py b/tests/test_project.py index 1d52fef0..687ff7fe 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -57,6 +57,8 @@ ARTIFACT_TYPE_RESOURCE = "RESOURCE" ARTIFACT_TYPE_MODULE = "MODULE" ARTIFACT_TYPE_HOOK = "HOOK" +CANARY_CREATE_FILE_SUFFIX = "001" +CANARY_PATCH_FILE_SUFFIX = "002" LANGUAGE = "BQHDBC" TYPE_NAME = "AWS::Color::Red" MODULE_TYPE_NAME = "AWS::Color::Red::MODULE" @@ -3017,3 +3019,494 @@ def test_generate_canary_files_empty_canary_settings(project): canary_folder_path = tmp_path / TARGET_CANARY_FOLDER assert not canary_root_path.exists() assert not canary_folder_path.exists() + + +def _get_mock_yaml_dump_call_arg( + call_args_list, canary_operation_suffix, arg_index=0, contract_test_count="2" +): + pattern = ( + rf"{CANARY_FILE_PREFIX}{contract_test_count}_{canary_operation_suffix}\.yaml$" + ) + return [ + call_item + for call_item in call_args_list + if re.search(pattern, call_item.args[1].name) + ][arg_index] + + +@patch("rpdk.core.project.yaml.dump") +def test_generate_canary_files_with_patch_inputs(mock_yaml_dump, project): + tmp_path = project.root + update_value_1 = "Value1b" + contract_test_data = { + "CreateInputs": { + "Property1": "Value1", + }, + "PatchInputs": [ + { + "op": "replace", + "path": "Property1", + "value": update_value_1, + } + ], + } + setup_contract_test_data(project.root, contract_test_data) + plugin = object() + data = json.dumps( + { + "artifact_type": "RESOURCE", + "language": LANGUAGE, + "runtime": RUNTIME, + "entrypoint": None, + "testEntrypoint": None, + "futureProperty": "value", + "typeName": "AWS::Example::Resource", + "canarySettings": { + FILE_GENERATION_ENABLED: True, + CONTRACT_TEST_FILE_NAMES: ["inputs_1.json", "inputs_2.json"], + }, + } + ) + patch_load = patch( + "rpdk.core.project.load_plugin", autospec=True, return_value=plugin + ) + + with patch_settings(project, data) as mock_open, patch_load as mock_load: + project.load_settings() + project.generate_canary_files() + mock_open.assert_called_once_with("r", encoding="utf-8") + mock_load.assert_called_once_with(LANGUAGE) + canary_root_path = tmp_path / TARGET_CANARY_ROOT_FOLDER + canary_folder_path = tmp_path / TARGET_CANARY_FOLDER + assert canary_root_path.exists() + assert canary_folder_path.exists() + + canary_files = list(canary_folder_path.glob(f"{CANARY_FILE_PREFIX}*")) + assert len(canary_files) == 4 + canary_files.sort() + assert canary_files[0].name == f"{CANARY_FILE_PREFIX}1_001.yaml" + assert canary_files[1].name == f"{CANARY_FILE_PREFIX}1_002.yaml" + assert canary_files[2].name == f"{CANARY_FILE_PREFIX}2_001.yaml" + assert canary_files[3].name == f"{CANARY_FILE_PREFIX}2_002.yaml" + + bootstrap_file = canary_root_path / CANARY_DEPENDENCY_FILE_NAME + assert bootstrap_file.exists() + + +@patch("rpdk.core.project.yaml.dump") +def test_create_template_file_with_patch_inputs(mock_yaml_dump, project): + update_value_1 = "Value1b" + update_value_2 = "Value2b" + + contract_test_data = { + "CreateInputs": { + "Property1": "Value1", + "Property2": "{{test123}}", + "Property3": {"Nested": "{{partition}}"}, + "Property4": ["{{region}}", "Value2"], + "Property5": "{{uuid}}", + "Property6": "{{account}}", + "Property7": "prefix-{{uuid}}-sufix", + }, + "PatchInputs": [ + { + "op": "replace", + "path": "Property1", + "value": update_value_1, + }, + { + "op": "replace", + "path": "Property2", + "value": "{{test1234}}", + }, + { + "op": "replace", + "path": "Property3", + "value": {"Nested": "{{partition}}"}, + }, + { + "op": "replace", + "path": "Property4", + "value": ["{{region}}", update_value_2], + }, + ], + } + setup_contract_test_data(project.root, contract_test_data) + plugin = object() + data = json.dumps( + { + "artifact_type": "RESOURCE", + "language": LANGUAGE, + "runtime": RUNTIME, + "entrypoint": None, + "testEntrypoint": None, + "futureProperty": "value", + "typeName": "AWS::Example::Resource", + "canarySettings": { + FILE_GENERATION_ENABLED: True, + CONTRACT_TEST_FILE_NAMES: ["inputs_1.json", "inputs_2.json"], + }, + } + ) + patch_load = patch( + "rpdk.core.project.load_plugin", autospec=True, return_value=plugin + ) + + with patch_settings(project, data) as mock_open, patch_load as mock_load: + project.load_settings() + project.generate_canary_files() + mock_open.assert_called_once_with("r", encoding="utf-8") + mock_load.assert_called_once_with(LANGUAGE) + # verify that PatchInputs canary template has the PatchInputs "replace" operation applied + expected_template_data = { + "Description": "Template for AWS::Example::Resource", + "Resources": { + "Resource": { + "Type": "AWS::Example::Resource", + "Properties": { + "Property1": update_value_1, + "Property2": {"Fn::ImportValue": "test1234"}, + "Property3": {"Nested": {"Fn::Sub": "${AWS::Partition}"}}, + "Property4": [{"Fn::Sub": "${AWS::Region}"}, update_value_2], + "Property5": ANY, + "Property6": {"Fn::Sub": "${AWS::AccountId}"}, + "Property7": ANY, + }, + } + }, + } + args, kwargs = _get_mock_yaml_dump_call_arg( + mock_yaml_dump.call_args_list, CANARY_PATCH_FILE_SUFFIX + ) + assert args[0] == expected_template_data + assert kwargs + + # verify that CreateInputs canary is correct + expected_template_data_create = { + "Description": "Template for AWS::Example::Resource", + "Resources": { + "Resource": { + "Type": "AWS::Example::Resource", + "Properties": { + "Property1": "Value1", + "Property2": {"Fn::ImportValue": "test123"}, + "Property3": {"Nested": {"Fn::Sub": "${AWS::Partition}"}}, + "Property4": [{"Fn::Sub": "${AWS::Region}"}, "Value2"], + "Property5": ANY, + "Property6": {"Fn::Sub": "${AWS::AccountId}"}, + "Property7": ANY, + }, + } + }, + } + args, kwargs = _get_mock_yaml_dump_call_arg( + mock_yaml_dump.call_args_list, CANARY_CREATE_FILE_SUFFIX + ) + assert args[0] == expected_template_data_create + assert kwargs + + +@patch("rpdk.core.project.yaml.dump") +def test_create_template_file_with_skipped_patch_operation(mock_yaml_dump, project): + update_value_1 = "Value1b" + update_value_2 = "Value2b" + contract_test_data = { + "CreateInputs": { + "Property1": "Value1", + "Property2": "{{test123}}", + "Property3": {"Nested": "{{partition}}"}, + "Property4": ["{{region}}", "Value2"], + "Property5": "{{uuid}}", + "Property6": "{{account}}", + "Property7": "prefix-{{uuid}}-sufix", + }, + "PatchInputs": [ + { + "op": "replace", + "path": "Property1", + "value": update_value_1, + }, + { + "op": "add", + "path": "Property4", + "value": ["{{region}}", update_value_2], + }, + ], + } + setup_contract_test_data(project.root, contract_test_data) + plugin = object() + data = json.dumps( + { + "artifact_type": "RESOURCE", + "language": LANGUAGE, + "runtime": RUNTIME, + "entrypoint": None, + "testEntrypoint": None, + "futureProperty": "value", + "typeName": "AWS::Example::Resource", + "canarySettings": { + FILE_GENERATION_ENABLED: True, + CONTRACT_TEST_FILE_NAMES: ["inputs_1.json", "inputs_2.json"], + }, + } + ) + patch_load = patch( + "rpdk.core.project.load_plugin", autospec=True, return_value=plugin + ) + + with patch_settings(project, data) as mock_open, patch_load as mock_load: + project.load_settings() + project.generate_canary_files() + mock_open.assert_called_once_with("r", encoding="utf-8") + mock_load.assert_called_once_with(LANGUAGE) + # verify that PatchInputs canary template has the PatchInputs "replace" operation applied + expected_template_data = { + "Description": "Template for AWS::Example::Resource", + "Resources": { + "Resource": { + "Type": "AWS::Example::Resource", + "Properties": { + "Property1": update_value_1, + "Property2": {"Fn::ImportValue": ANY}, + "Property3": {"Nested": {"Fn::Sub": "${AWS::Partition}"}}, + "Property4": [{"Fn::Sub": "${AWS::Region}"}, "Value2"], + "Property5": ANY, + "Property6": {"Fn::Sub": "${AWS::AccountId}"}, + "Property7": ANY, + }, + } + }, + } + args, kwargs = _get_mock_yaml_dump_call_arg( + mock_yaml_dump.call_args_list, CANARY_PATCH_FILE_SUFFIX + ) + assert args[0] == expected_template_data + assert kwargs + + # verify that CreateInputs canary is correct + expected_template_data_create = { + "Description": "Template for AWS::Example::Resource", + "Resources": { + "Resource": { + "Type": "AWS::Example::Resource", + "Properties": { + "Property1": "Value1", + "Property2": {"Fn::ImportValue": ANY}, + "Property3": {"Nested": {"Fn::Sub": "${AWS::Partition}"}}, + "Property4": [{"Fn::Sub": "${AWS::Region}"}, "Value2"], + "Property5": ANY, + "Property6": {"Fn::Sub": "${AWS::AccountId}"}, + "Property7": ANY, + }, + } + }, + } + args, kwargs = args, kwargs = _get_mock_yaml_dump_call_arg( + mock_yaml_dump.call_args_list, CANARY_CREATE_FILE_SUFFIX + ) + assert args[0] == expected_template_data_create + assert kwargs + + +@patch("rpdk.core.project.yaml.dump") +def test_create_template_file_with_patch_inputs_missing_from_create( + mock_yaml_dump, project +): + update_value_2 = "Value2b" + contract_test_data = { + "CreateInputs": { + "Property1": "Value1", + "Property2": "{{test123}}", + "Property3": {"Nested": "{{partition}}"}, + "Property5": "{{uuid}}", + "Property6": "{{account}}", + "Property7": "prefix-{{uuid}}-sufix", + }, + "PatchInputs": [ + { + "op": "replace", + "path": "Property4", + "value": ["{{region}}", update_value_2], + }, + ], + } + setup_contract_test_data(project.root, contract_test_data) + plugin = object() + data = json.dumps( + { + "artifact_type": "RESOURCE", + "language": LANGUAGE, + "runtime": RUNTIME, + "entrypoint": None, + "testEntrypoint": None, + "futureProperty": "value", + "typeName": "AWS::Example::Resource", + "canarySettings": { + FILE_GENERATION_ENABLED: True, + CONTRACT_TEST_FILE_NAMES: ["inputs_1.json", "inputs_2.json"], + }, + } + ) + patch_load = patch( + "rpdk.core.project.load_plugin", autospec=True, return_value=plugin + ) + + with patch_settings(project, data) as mock_open, patch_load as mock_load: + project.load_settings() + project.generate_canary_files() + mock_open.assert_called_once_with("r", encoding="utf-8") + mock_load.assert_called_once_with(LANGUAGE) + # verify that PatchInputs canary template has the PatchInputs "replace" operation applied + expected_template_data = { + "Description": "Template for AWS::Example::Resource", + "Resources": { + "Resource": { + "Type": "AWS::Example::Resource", + "Properties": { + "Property1": "Value1", + "Property2": {"Fn::ImportValue": ANY}, + "Property3": {"Nested": {"Fn::Sub": "${AWS::Partition}"}}, + "Property4": [{"Fn::Sub": "${AWS::Region}"}, update_value_2], + "Property5": ANY, + "Property6": {"Fn::Sub": "${AWS::AccountId}"}, + "Property7": ANY, + }, + } + }, + } + args, kwargs = _get_mock_yaml_dump_call_arg( + mock_yaml_dump.call_args_list, CANARY_PATCH_FILE_SUFFIX + ) + assert args[0] == expected_template_data + assert kwargs + + # verify that CreateInputs canary is correct + expected_template_data_create = { + "Description": "Template for AWS::Example::Resource", + "Resources": { + "Resource": { + "Type": "AWS::Example::Resource", + "Properties": { + "Property1": "Value1", + "Property2": {"Fn::ImportValue": ANY}, + "Property3": {"Nested": {"Fn::Sub": "${AWS::Partition}"}}, + "Property5": ANY, + "Property6": {"Fn::Sub": "${AWS::AccountId}"}, + "Property7": ANY, + }, + } + }, + } + args, kwargs = _get_mock_yaml_dump_call_arg( + mock_yaml_dump.call_args_list, CANARY_CREATE_FILE_SUFFIX + ) + assert args[0] == expected_template_data_create + assert kwargs + + +@patch("rpdk.core.project.yaml.dump") +def test_create_template_file_with_nested_patch_inputs(mock_yaml_dump, project): + update_value_1 = "Value_Nested1b" + update_value_2 = "Value_Nested2b" + contract_test_data = { + "CreateInputs": { + "Property1": "Value1", + "Property8": { + "Nested": { + "PropertyA": "Value_Nested1", + "PropertyB": ["{{region}}", "Value_Nested2"], + } + }, + }, + "PatchInputs": [ + { + "op": "replace", + "path": "Property8/Nested/PropertyA", + "value": update_value_1, + }, + { + "op": "replace", + "path": "Property8/Nested/PropertyB", + "value": ["{{region}}", update_value_2], + }, + ], + } + setup_contract_test_data(project.root, contract_test_data) + plugin = object() + data = json.dumps( + { + "artifact_type": "RESOURCE", + "language": LANGUAGE, + "runtime": RUNTIME, + "entrypoint": None, + "testEntrypoint": None, + "futureProperty": "value", + "typeName": "AWS::Example::Resource", + "canarySettings": { + FILE_GENERATION_ENABLED: True, + CONTRACT_TEST_FILE_NAMES: ["inputs_1.json", "inputs_2.json"], + }, + } + ) + patch_load = patch( + "rpdk.core.project.load_plugin", autospec=True, return_value=plugin + ) + + with patch_settings(project, data) as mock_open, patch_load as mock_load: + project.load_settings() + project.generate_canary_files() + mock_open.assert_called_once_with("r", encoding="utf-8") + mock_load.assert_called_once_with(LANGUAGE) + # verify that PatchInputs canary template has the PatchInputs "replace" operation applied + expected_template_data = { + "Description": "Template for AWS::Example::Resource", + "Resources": { + "Resource": { + "Type": "AWS::Example::Resource", + "Properties": { + "Property1": "Value1", + "Property8": { + "Nested": { + "PropertyA": update_value_1, + "PropertyB": [ + {"Fn::Sub": "${AWS::Region}"}, + update_value_2, + ], + } + }, + }, + } + }, + } + args, kwargs = _get_mock_yaml_dump_call_arg( + mock_yaml_dump.call_args_list, CANARY_PATCH_FILE_SUFFIX + ) + assert args[0] == expected_template_data + assert kwargs + + # verify that CreateInputs canary is correct + expected_template_data_create = { + "Description": "Template for AWS::Example::Resource", + "Resources": { + "Resource": { + "Type": "AWS::Example::Resource", + "Properties": { + "Property1": "Value1", + "Property8": { + "Nested": { + "PropertyA": "Value_Nested1", + "PropertyB": [ + {"Fn::Sub": "${AWS::Region}"}, + "Value_Nested2", + ], + } + }, + }, + } + }, + } + args, kwargs = _get_mock_yaml_dump_call_arg( + mock_yaml_dump.call_args_list, CANARY_CREATE_FILE_SUFFIX + ) + assert args[0] == expected_template_data_create + assert kwargs From d7ae48245dbb65a913d8746c397073a57c50ff69 Mon Sep 17 00:00:00 2001 From: Marc Cunningham Date: Wed, 5 Jun 2024 15:31:01 -0700 Subject: [PATCH 2/3] Add functionality for add/remove operations. Fix dynamic variables --- src/rpdk/core/project.py | 78 ++++++-- tests/test_project.py | 391 ++++++++++++++++++++++++++++++++++++--- 2 files changed, 421 insertions(+), 48 deletions(-) diff --git a/src/rpdk/core/project.py b/src/rpdk/core/project.py index 9205816d..bbd45b73 100644 --- a/src/rpdk/core/project.py +++ b/src/rpdk/core/project.py @@ -1346,6 +1346,15 @@ def _generate_stack_template_files(self) -> None: json_data = json.load(f) resource_name = self.type_info[2] + self._save_stack_template_data( + resource_name, + count, + stack_template_folder, + self._replace_dynamic_values( + json_data["CreateInputs"], + ), + "001", + ) if "PatchInputs" in json_data: deepcopy_create_input = copy.deepcopy(json_data["CreateInputs"]) self._save_stack_template_data( @@ -1353,17 +1362,11 @@ def _generate_stack_template_files(self) -> None: count, stack_template_folder, self._apply_patch_inputs_to_create_inputs( - json_data["PatchInputs"], deepcopy_create_input + json_data["PatchInputs"], + deepcopy_create_input, ), "002", ) - self._save_stack_template_data( - resource_name, - count, - stack_template_folder, - json_data["CreateInputs"], - "001", - ) def _save_stack_template_data( self, @@ -1378,9 +1381,7 @@ def _save_stack_template_data( "Resources": { f"{resource_name}": { "Type": self.type_name, - "Properties": self._replace_dynamic_values( - properties_data, - ), + "Properties": properties_data, } }, } @@ -1394,15 +1395,41 @@ def _save_stack_template_data( def _apply_patch_inputs_to_create_inputs( self, patch_inputs: Dict[str, Any], create_inputs: Dict[str, Any] ) -> Dict[str, Any]: - output = create_inputs for patch_input in patch_inputs: - if patch_input["op"] == "replace": - key_list = patch_input["path"].split("/") - current_output = output - for key in key_list[:-1]: - current_output = current_output.setdefault(key, {}) - current_output[key_list[-1]] = patch_input["value"] - return output + self._apply_patch_input_to_create_input(patch_input, create_inputs) + return create_inputs + + def _apply_patch_input_to_create_input( + self, patch_input: Any, create_inputs: Dict[str, Any] + ) -> Dict[str, Any]: + op = patch_input.get("op") + path = patch_input.get("path") + if op not in {"replace", "remove", "add"}: + return create_inputs + key_list = [self._translate_integer_key(key) for key in path.split("/") if key] + current_input = create_inputs + for key in key_list[:-1]: + try: + current_input = current_input[key] + except (KeyError, IndexError): + LOG.warning("Canary generation skipped for invalid path: %s", path) + return create_inputs + patch_key = key_list[-1] + if op == "remove": + del current_input[patch_key] + elif op == "add" and isinstance(current_input, list): + current_input.insert(patch_key, patch_input["value"]) + self._replace_dynamic_values_with_root_key(current_input, patch_key) + else: + # remaining use cases for both "add" and "replace" operations + current_input[patch_key] = patch_input["value"] + self._replace_dynamic_values_with_root_key(current_input, patch_key) + return create_inputs + + def _translate_integer_key(self, key: str) -> Any: + if key.isdigit(): + key = int(key) + return key def _replace_dynamic_values(self, properties: Dict[str, Any]) -> Dict[str, Any]: for key, value in properties.items(): @@ -1415,6 +1442,19 @@ def _replace_dynamic_values(self, properties: Dict[str, Any]) -> Dict[str, Any]: properties[key] = return_value return properties + def _replace_dynamic_values_with_root_key( + self, properties: Dict[str, Any], root_key=None + ) -> Dict[str, Any]: + value = properties[root_key] + if isinstance(value, dict): + properties[root_key] = self._replace_dynamic_values(value) + elif isinstance(value, list): + properties[root_key] = [self._replace_dynamic_value(item) for item in value] + else: + return_value = self._replace_dynamic_value(value) + properties[root_key] = return_value + return properties + def _replace_dynamic_value(self, original_value: Any) -> Any: pattern = r"\{\{(.*?)\}\}" diff --git a/tests/test_project.py b/tests/test_project.py index 687ff7fe..43f07fd4 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -3116,7 +3116,7 @@ def test_create_template_file_with_patch_inputs(mock_yaml_dump, project): }, { "op": "replace", - "path": "Property2", + "path": "/Property2", "value": "{{test1234}}", }, { @@ -3157,7 +3157,7 @@ def test_create_template_file_with_patch_inputs(mock_yaml_dump, project): project.generate_canary_files() mock_open.assert_called_once_with("r", encoding="utf-8") mock_load.assert_called_once_with(LANGUAGE) - # verify that PatchInputs canary template has the PatchInputs "replace" operation applied + expected_template_data = { "Description": "Template for AWS::Example::Resource", "Resources": { @@ -3180,6 +3180,8 @@ def test_create_template_file_with_patch_inputs(mock_yaml_dump, project): ) assert args[0] == expected_template_data assert kwargs + # verify that dynamically generated variables will be equal between patch and create canaries + patch_property5 = args[0]["Resources"]["Resource"]["Properties"]["Property5"] # verify that CreateInputs canary is correct expected_template_data_create = { @@ -3204,32 +3206,30 @@ def test_create_template_file_with_patch_inputs(mock_yaml_dump, project): ) assert args[0] == expected_template_data_create assert kwargs + assert ( + patch_property5 == args[0]["Resources"]["Resource"]["Properties"]["Property5"] + ) @patch("rpdk.core.project.yaml.dump") -def test_create_template_file_with_skipped_patch_operation(mock_yaml_dump, project): +def test_create_template_file_by_list_index(mock_yaml_dump, project): update_value_1 = "Value1b" update_value_2 = "Value2b" contract_test_data = { "CreateInputs": { - "Property1": "Value1", - "Property2": "{{test123}}", - "Property3": {"Nested": "{{partition}}"}, - "Property4": ["{{region}}", "Value2"], - "Property5": "{{uuid}}", - "Property6": "{{account}}", - "Property7": "prefix-{{uuid}}-sufix", + "Property1": ["{{region}}", "Value1"], + "Property2": ["{{region}}", "Value2"], }, "PatchInputs": [ { "op": "replace", - "path": "Property1", + "path": "Property1/1", "value": update_value_1, }, { "op": "add", - "path": "Property4", - "value": ["{{region}}", update_value_2], + "path": "Property2/1", + "value": update_value_2, }, ], } @@ -3259,20 +3259,19 @@ def test_create_template_file_with_skipped_patch_operation(mock_yaml_dump, proje project.generate_canary_files() mock_open.assert_called_once_with("r", encoding="utf-8") mock_load.assert_called_once_with(LANGUAGE) - # verify that PatchInputs canary template has the PatchInputs "replace" operation applied + expected_template_data = { "Description": "Template for AWS::Example::Resource", "Resources": { "Resource": { "Type": "AWS::Example::Resource", "Properties": { - "Property1": update_value_1, - "Property2": {"Fn::ImportValue": ANY}, - "Property3": {"Nested": {"Fn::Sub": "${AWS::Partition}"}}, - "Property4": [{"Fn::Sub": "${AWS::Region}"}, "Value2"], - "Property5": ANY, - "Property6": {"Fn::Sub": "${AWS::AccountId}"}, - "Property7": ANY, + "Property1": [{"Fn::Sub": "${AWS::Region}"}, update_value_1], + "Property2": [ + {"Fn::Sub": "${AWS::Region}"}, + update_value_2, + "Value2", + ], }, } }, @@ -3283,8 +3282,63 @@ def test_create_template_file_with_skipped_patch_operation(mock_yaml_dump, proje assert args[0] == expected_template_data assert kwargs - # verify that CreateInputs canary is correct - expected_template_data_create = { + +@patch("rpdk.core.project.yaml.dump") +def test_create_template_file_with_skipped_patch_operation(mock_yaml_dump, project): + update_value_1 = "Value1b" + update_value_2 = "Value2b" + contract_test_data = { + "CreateInputs": { + "Property1": "Value1", + "Property2": "{{test123}}", + "Property3": {"Nested": "{{partition}}"}, + "Property4": ["{{region}}", "Value2"], + "Property5": "{{uuid}}", + "Property6": "{{account}}", + "Property7": "prefix-{{uuid}}-sufix", + }, + "PatchInputs": [ + { + "op": "test", + "path": "Property1", + "value": update_value_1, + }, + { + "op": "move", + "path": "Property4", + "value": update_value_2, + }, + {"op": "copy", "from": "Property4", "path": "Property2"}, + ], + } + setup_contract_test_data(project.root, contract_test_data) + plugin = object() + data = json.dumps( + { + "artifact_type": "RESOURCE", + "language": LANGUAGE, + "runtime": RUNTIME, + "entrypoint": None, + "testEntrypoint": None, + "futureProperty": "value", + "typeName": "AWS::Example::Resource", + "canarySettings": { + FILE_GENERATION_ENABLED: True, + CONTRACT_TEST_FILE_NAMES: ["inputs_1.json", "inputs_2.json"], + }, + } + ) + patch_load = patch( + "rpdk.core.project.load_plugin", autospec=True, return_value=plugin + ) + + with patch_settings(project, data) as mock_open, patch_load as mock_load: + project.load_settings() + project.generate_canary_files() + mock_open.assert_called_once_with("r", encoding="utf-8") + mock_load.assert_called_once_with(LANGUAGE) + + expected_template_data = { "Description": "Template for AWS::Example::Resource", "Resources": { "Resource": { @@ -3301,10 +3355,10 @@ def test_create_template_file_with_skipped_patch_operation(mock_yaml_dump, proje } }, } - args, kwargs = args, kwargs = _get_mock_yaml_dump_call_arg( - mock_yaml_dump.call_args_list, CANARY_CREATE_FILE_SUFFIX + args, kwargs = _get_mock_yaml_dump_call_arg( + mock_yaml_dump.call_args_list, CANARY_PATCH_FILE_SUFFIX ) - assert args[0] == expected_template_data_create + assert args[0] == expected_template_data assert kwargs @@ -3313,6 +3367,7 @@ def test_create_template_file_with_patch_inputs_missing_from_create( mock_yaml_dump, project ): update_value_2 = "Value2b" + update_value_8 = "Value8" contract_test_data = { "CreateInputs": { "Property1": "Value1", @@ -3328,6 +3383,11 @@ def test_create_template_file_with_patch_inputs_missing_from_create( "path": "Property4", "value": ["{{region}}", update_value_2], }, + { + "op": "add", + "path": "Property8", + "value": update_value_8, + }, ], } setup_contract_test_data(project.root, contract_test_data) @@ -3356,7 +3416,7 @@ def test_create_template_file_with_patch_inputs_missing_from_create( project.generate_canary_files() mock_open.assert_called_once_with("r", encoding="utf-8") mock_load.assert_called_once_with(LANGUAGE) - # verify that PatchInputs canary template has the PatchInputs "replace" operation applied + expected_template_data = { "Description": "Template for AWS::Example::Resource", "Resources": { @@ -3370,6 +3430,7 @@ def test_create_template_file_with_patch_inputs_missing_from_create( "Property5": ANY, "Property6": {"Fn::Sub": "${AWS::AccountId}"}, "Property7": ANY, + "Property8": update_value_8, }, } }, @@ -3405,7 +3466,75 @@ def test_create_template_file_with_patch_inputs_missing_from_create( @patch("rpdk.core.project.yaml.dump") -def test_create_template_file_with_nested_patch_inputs(mock_yaml_dump, project): +def test_create_template_file_with_skipping_patch_inputs_with_invalid_path( + mock_yaml_dump, project +): + update_value1 = "Value1b" + update_value_2 = "Value2b" + contract_test_data = { + "CreateInputs": { + "Property1": "Value1", + }, + "PatchInputs": [ + { + "op": "replace", + "path": "Property1", + "value": update_value1, + }, + { + "op": "replace", + "path": "Property4/SubProperty4", + "value": update_value_2, + }, + ], + } + setup_contract_test_data(project.root, contract_test_data) + plugin = object() + data = json.dumps( + { + "artifact_type": "RESOURCE", + "language": LANGUAGE, + "runtime": RUNTIME, + "entrypoint": None, + "testEntrypoint": None, + "futureProperty": "value", + "typeName": "AWS::Example::Resource", + "canarySettings": { + FILE_GENERATION_ENABLED: True, + CONTRACT_TEST_FILE_NAMES: ["inputs_1.json", "inputs_2.json"], + }, + } + ) + patch_load = patch( + "rpdk.core.project.load_plugin", autospec=True, return_value=plugin + ) + + with patch_settings(project, data) as mock_open, patch_load as mock_load: + project.load_settings() + project.generate_canary_files() + mock_open.assert_called_once_with("r", encoding="utf-8") + mock_load.assert_called_once_with(LANGUAGE) + + expected_template_data = { + "Description": "Template for AWS::Example::Resource", + "Resources": { + "Resource": { + "Type": "AWS::Example::Resource", + "Properties": { + "Property1": update_value1, + }, + } + }, + } + args, kwargs = _get_mock_yaml_dump_call_arg( + mock_yaml_dump.call_args_list, CANARY_PATCH_FILE_SUFFIX + ) + assert args[0] == expected_template_data + assert kwargs + + +@patch("rpdk.core.project.yaml.dump") +def test_create_template_file_with_nested_replace_patch_inputs(mock_yaml_dump, project): update_value_1 = "Value_Nested1b" update_value_2 = "Value_Nested2b" contract_test_data = { @@ -3457,7 +3586,7 @@ def test_create_template_file_with_nested_patch_inputs(mock_yaml_dump, project): project.generate_canary_files() mock_open.assert_called_once_with("r", encoding="utf-8") mock_load.assert_called_once_with(LANGUAGE) - # verify that PatchInputs canary template has the PatchInputs "replace" operation applied + expected_template_data = { "Description": "Template for AWS::Example::Resource", "Resources": { @@ -3510,3 +3639,207 @@ def test_create_template_file_with_nested_patch_inputs(mock_yaml_dump, project): ) assert args[0] == expected_template_data_create assert kwargs + + +@patch("rpdk.core.project.yaml.dump") +def test_create_template_file_with_nested_remove_patch_inputs(mock_yaml_dump, project): + update_value_1 = "Value_Nested1b" + contract_test_data = { + "CreateInputs": { + "Property1": "Value1", + "Property8": { + "Nested": { + "PropertyA": "Value_Nested1", + "PropertyB": ["{{region}}", "Value_Nested2"], + } + }, + }, + "PatchInputs": [ + { + "op": "replace", + "path": "Property8/Nested/PropertyA", + "value": update_value_1, + }, + { + "op": "remove", + "path": "Property8/Nested/PropertyB/1", + }, + ], + } + setup_contract_test_data(project.root, contract_test_data) + plugin = object() + data = json.dumps( + { + "artifact_type": "RESOURCE", + "language": LANGUAGE, + "runtime": RUNTIME, + "entrypoint": None, + "testEntrypoint": None, + "futureProperty": "value", + "typeName": "AWS::Example::Resource", + "canarySettings": { + FILE_GENERATION_ENABLED: True, + CONTRACT_TEST_FILE_NAMES: ["inputs_1.json", "inputs_2.json"], + }, + } + ) + patch_load = patch( + "rpdk.core.project.load_plugin", autospec=True, return_value=plugin + ) + + with patch_settings(project, data) as mock_open, patch_load as mock_load: + project.load_settings() + project.generate_canary_files() + mock_open.assert_called_once_with("r", encoding="utf-8") + mock_load.assert_called_once_with(LANGUAGE) + expected_template_data = { + "Description": "Template for AWS::Example::Resource", + "Resources": { + "Resource": { + "Type": "AWS::Example::Resource", + "Properties": { + "Property1": "Value1", + "Property8": { + "Nested": { + "PropertyA": update_value_1, + "PropertyB": [ + {"Fn::Sub": "${AWS::Region}"}, + ], + } + }, + }, + } + }, + } + args, kwargs = _get_mock_yaml_dump_call_arg( + mock_yaml_dump.call_args_list, CANARY_PATCH_FILE_SUFFIX + ) + assert args[0] == expected_template_data + assert kwargs + + # verify that CreateInputs canary is correct + expected_template_data_create = { + "Description": "Template for AWS::Example::Resource", + "Resources": { + "Resource": { + "Type": "AWS::Example::Resource", + "Properties": { + "Property1": "Value1", + "Property8": { + "Nested": { + "PropertyA": "Value_Nested1", + "PropertyB": [ + {"Fn::Sub": "${AWS::Region}"}, + "Value_Nested2", + ], + } + }, + }, + } + }, + } + args, kwargs = _get_mock_yaml_dump_call_arg( + mock_yaml_dump.call_args_list, CANARY_CREATE_FILE_SUFFIX + ) + assert args[0] == expected_template_data_create + assert kwargs + + +@patch("rpdk.core.project.yaml.dump") +def test_create_template_file_with_nested_add_patch_inputs(mock_yaml_dump, project): + update_value_2 = "Value_Nested2b" + contract_test_data = { + "CreateInputs": { + "Property8": { + "Nested": { + "PropertyA": "Value_Nested1", + "PropertyB": ["{{region}}", "Value_Nested2"], + } + }, + }, + "PatchInputs": [ + { + "op": "add", + "path": "Property8/Nested/PropertyB/2", + "value": update_value_2, + }, + ], + } + setup_contract_test_data(project.root, contract_test_data) + plugin = object() + data = json.dumps( + { + "artifact_type": "RESOURCE", + "language": LANGUAGE, + "runtime": RUNTIME, + "entrypoint": None, + "testEntrypoint": None, + "futureProperty": "value", + "typeName": "AWS::Example::Resource", + "canarySettings": { + FILE_GENERATION_ENABLED: True, + CONTRACT_TEST_FILE_NAMES: ["inputs_1.json", "inputs_2.json"], + }, + } + ) + patch_load = patch( + "rpdk.core.project.load_plugin", autospec=True, return_value=plugin + ) + + with patch_settings(project, data) as mock_open, patch_load as mock_load: + project.load_settings() + project.generate_canary_files() + mock_open.assert_called_once_with("r", encoding="utf-8") + mock_load.assert_called_once_with(LANGUAGE) + + expected_template_data = { + "Description": "Template for AWS::Example::Resource", + "Resources": { + "Resource": { + "Type": "AWS::Example::Resource", + "Properties": { + "Property8": { + "Nested": { + "PropertyA": "Value_Nested1", + "PropertyB": [ + {"Fn::Sub": "${AWS::Region}"}, + "Value_Nested2", + update_value_2, + ], + } + }, + }, + } + }, + } + args, kwargs = _get_mock_yaml_dump_call_arg( + mock_yaml_dump.call_args_list, CANARY_PATCH_FILE_SUFFIX + ) + assert args[0] == expected_template_data + assert kwargs + + # verify that CreateInputs canary is correct + expected_template_data_create = { + "Description": "Template for AWS::Example::Resource", + "Resources": { + "Resource": { + "Type": "AWS::Example::Resource", + "Properties": { + "Property8": { + "Nested": { + "PropertyA": "Value_Nested1", + "PropertyB": [ + {"Fn::Sub": "${AWS::Region}"}, + "Value_Nested2", + ], + } + }, + }, + } + }, + } + args, kwargs = _get_mock_yaml_dump_call_arg( + mock_yaml_dump.call_args_list, CANARY_CREATE_FILE_SUFFIX + ) + assert args[0] == expected_template_data_create + assert kwargs From c7f71034e39d8fbbbc199575e0305bf478c12ee4 Mon Sep 17 00:00:00 2001 From: Marc Cunningham Date: Fri, 7 Jun 2024 11:28:49 -0700 Subject: [PATCH 3/3] Refactor to use jsonpatch and improve naming. --- setup.cfg | 2 +- setup.py | 1 + src/rpdk/core/project.py | 78 ++++++++++++++++------------------------ tests/test_project.py | 65 +++++++++++++-------------------- 4 files changed, 57 insertions(+), 89 deletions(-) diff --git a/setup.cfg b/setup.cfg index 4933275c..d401f8a8 100644 --- a/setup.cfg +++ b/setup.cfg @@ -29,7 +29,7 @@ include_trailing_comma = true combine_as_imports = True force_grid_wrap = 0 known_first_party = rpdk -known_third_party = boto3,botocore,cfn_tools,cfnlint,colorama,docker,hypothesis,jinja2,jsonschema,nested_lookup,ordered_set,pkg_resources,pytest,pytest_localserver,requests,setuptools,yaml +known_third_party = boto3,botocore,cfn_tools,cfnlint,colorama,docker,hypothesis,jinja2,jsonpatch,jsonschema,nested_lookup,ordered_set,pkg_resources,pytest,pytest_localserver,requests,setuptools,yaml [tool:pytest] # can't do anything about 3rd part modules, so don't spam us diff --git a/setup.py b/setup.py index 03c9532d..83e6cd18 100644 --- a/setup.py +++ b/setup.py @@ -41,6 +41,7 @@ def find_version(*file_paths): "boto3>=1.10.20", "Jinja2>=3.1.2", "markupsafe>=2.1.0", + "jsonpatch", "jsonschema>=3.0.0,<=4.17.3", "pytest>=4.5.0", "pytest-random-order>=1.0.4", diff --git a/src/rpdk/core/project.py b/src/rpdk/core/project.py index bbd45b73..38c7ffd9 100644 --- a/src/rpdk/core/project.py +++ b/src/rpdk/core/project.py @@ -1,5 +1,4 @@ # pylint: disable=too-many-lines -import copy import json import logging import os @@ -12,6 +11,7 @@ from typing import Any, Dict from uuid import uuid4 +import jsonpatch import yaml from botocore.exceptions import ClientError, WaiterError from jinja2 import Environment, PackageLoader, select_autoescape @@ -64,6 +64,13 @@ TARGET_CANARY_FOLDER = "canary-bundle/canary" RPDK_CONFIG_FILE = ".rpdk-config" CANARY_FILE_PREFIX = "canary" +CANARY_FILE_CREATE_SUFFIX = "001" +CANARY_FILE_UPDATE_SUFFIX = "002" +CANARY_SUPPORTED_PATCH_INPUT_OPERATIONS = {"replace", "remove", "add"} +CREATE_INPUTS_KEY = "CreateInputs" +PATCH_INPUTS_KEY = "PatchInputs" +PATCH_VALUE_KEY = "value" +PATCH_OPERATION_KEY = "op" CONTRACT_TEST_DEPENDENCY_FILE_NAME = "dependencies.yml" CANARY_DEPENDENCY_FILE_NAME = "bootstrap.yaml" CANARY_SETTINGS = "canarySettings" @@ -1351,21 +1358,23 @@ def _generate_stack_template_files(self) -> None: count, stack_template_folder, self._replace_dynamic_values( - json_data["CreateInputs"], + json_data[CREATE_INPUTS_KEY], ), - "001", + CANARY_FILE_CREATE_SUFFIX, ) - if "PatchInputs" in json_data: - deepcopy_create_input = copy.deepcopy(json_data["CreateInputs"]) + if PATCH_INPUTS_KEY in json_data: + supported_patch_inputs = self._translate_supported_patch_inputs( + json_data[PATCH_INPUTS_KEY] + ) + patch_data = jsonpatch.apply_patch( + json_data[CREATE_INPUTS_KEY], supported_patch_inputs, in_place=False + ) self._save_stack_template_data( resource_name, count, stack_template_folder, - self._apply_patch_inputs_to_create_inputs( - json_data["PatchInputs"], - deepcopy_create_input, - ), - "002", + patch_data, + CANARY_FILE_UPDATE_SUFFIX, ) def _save_stack_template_data( @@ -1392,44 +1401,19 @@ def _save_stack_template_data( with stack_template_file_path.open("w") as stack_template_file: yaml.dump(stack_template_data, stack_template_file, indent=2) - def _apply_patch_inputs_to_create_inputs( - self, patch_inputs: Dict[str, Any], create_inputs: Dict[str, Any] - ) -> Dict[str, Any]: + def _translate_supported_patch_inputs(self, patch_inputs: Any) -> Any: + output = [] for patch_input in patch_inputs: - self._apply_patch_input_to_create_input(patch_input, create_inputs) - return create_inputs - - def _apply_patch_input_to_create_input( - self, patch_input: Any, create_inputs: Dict[str, Any] - ) -> Dict[str, Any]: - op = patch_input.get("op") - path = patch_input.get("path") - if op not in {"replace", "remove", "add"}: - return create_inputs - key_list = [self._translate_integer_key(key) for key in path.split("/") if key] - current_input = create_inputs - for key in key_list[:-1]: - try: - current_input = current_input[key] - except (KeyError, IndexError): - LOG.warning("Canary generation skipped for invalid path: %s", path) - return create_inputs - patch_key = key_list[-1] - if op == "remove": - del current_input[patch_key] - elif op == "add" and isinstance(current_input, list): - current_input.insert(patch_key, patch_input["value"]) - self._replace_dynamic_values_with_root_key(current_input, patch_key) - else: - # remaining use cases for both "add" and "replace" operations - current_input[patch_key] = patch_input["value"] - self._replace_dynamic_values_with_root_key(current_input, patch_key) - return create_inputs - - def _translate_integer_key(self, key: str) -> Any: - if key.isdigit(): - key = int(key) - return key + if ( + patch_input.get(PATCH_OPERATION_KEY) + in CANARY_SUPPORTED_PATCH_INPUT_OPERATIONS + ): + if PATCH_VALUE_KEY in patch_input: + self._replace_dynamic_values_with_root_key( + patch_input, PATCH_VALUE_KEY + ) + output.append(patch_input) + return output def _replace_dynamic_values(self, properties: Dict[str, Any]) -> Dict[str, Any]: for key, value in properties.items(): diff --git a/tests/test_project.py b/tests/test_project.py index 43f07fd4..3789b51f 100644 --- a/tests/test_project.py +++ b/tests/test_project.py @@ -17,6 +17,7 @@ from shutil import copyfile from unittest.mock import ANY, MagicMock, Mock, call, patch +import jsonpatch import pytest import yaml from botocore.exceptions import ClientError, WaiterError @@ -3045,7 +3046,7 @@ def test_generate_canary_files_with_patch_inputs(mock_yaml_dump, project): "PatchInputs": [ { "op": "replace", - "path": "Property1", + "path": "/Property1", "value": update_value_1, } ], @@ -3111,7 +3112,7 @@ def test_create_template_file_with_patch_inputs(mock_yaml_dump, project): "PatchInputs": [ { "op": "replace", - "path": "Property1", + "path": "/Property1", "value": update_value_1, }, { @@ -3121,12 +3122,12 @@ def test_create_template_file_with_patch_inputs(mock_yaml_dump, project): }, { "op": "replace", - "path": "Property3", + "path": "/Property3", "value": {"Nested": "{{partition}}"}, }, { "op": "replace", - "path": "Property4", + "path": "/Property4", "value": ["{{region}}", update_value_2], }, ], @@ -3223,12 +3224,12 @@ def test_create_template_file_by_list_index(mock_yaml_dump, project): "PatchInputs": [ { "op": "replace", - "path": "Property1/1", + "path": "/Property1/1", "value": update_value_1, }, { "op": "add", - "path": "Property2/1", + "path": "/Property2/1", "value": update_value_2, }, ], @@ -3300,15 +3301,15 @@ def test_create_template_file_with_skipped_patch_operation(mock_yaml_dump, proje "PatchInputs": [ { "op": "test", - "path": "Property1", + "path": "/Property1", "value": update_value_1, }, { "op": "move", - "path": "Property4", + "path": "/Property4", "value": update_value_2, }, - {"op": "copy", "from": "Property4", "path": "Property2"}, + {"op": "copy", "from": "Property4", "path": "/Property2"}, ], } setup_contract_test_data(project.root, contract_test_data) @@ -3379,13 +3380,13 @@ def test_create_template_file_with_patch_inputs_missing_from_create( }, "PatchInputs": [ { - "op": "replace", - "path": "Property4", + "op": "add", + "path": "/Property4", "value": ["{{region}}", update_value_2], }, { "op": "add", - "path": "Property8", + "path": "/Property8", "value": update_value_8, }, ], @@ -3466,9 +3467,7 @@ def test_create_template_file_with_patch_inputs_missing_from_create( @patch("rpdk.core.project.yaml.dump") -def test_create_template_file_with_skipping_patch_inputs_with_invalid_path( - mock_yaml_dump, project -): +def test_create_template_file_throws_error_with_invalid_path(mock_yaml_dump, project): update_value1 = "Value1b" update_value_2 = "Value2b" contract_test_data = { @@ -3478,12 +3477,12 @@ def test_create_template_file_with_skipping_patch_inputs_with_invalid_path( "PatchInputs": [ { "op": "replace", - "path": "Property1", + "path": "/Property1", "value": update_value1, }, { - "op": "replace", - "path": "Property4/SubProperty4", + "op": "add", + "path": "/Property4/SubProperty4", "value": update_value_2, }, ], @@ -3511,27 +3510,11 @@ def test_create_template_file_with_skipping_patch_inputs_with_invalid_path( with patch_settings(project, data) as mock_open, patch_load as mock_load: project.load_settings() - project.generate_canary_files() + with pytest.raises(jsonpatch.JsonPointerException): + project.generate_canary_files() mock_open.assert_called_once_with("r", encoding="utf-8") mock_load.assert_called_once_with(LANGUAGE) - expected_template_data = { - "Description": "Template for AWS::Example::Resource", - "Resources": { - "Resource": { - "Type": "AWS::Example::Resource", - "Properties": { - "Property1": update_value1, - }, - } - }, - } - args, kwargs = _get_mock_yaml_dump_call_arg( - mock_yaml_dump.call_args_list, CANARY_PATCH_FILE_SUFFIX - ) - assert args[0] == expected_template_data - assert kwargs - @patch("rpdk.core.project.yaml.dump") def test_create_template_file_with_nested_replace_patch_inputs(mock_yaml_dump, project): @@ -3550,12 +3533,12 @@ def test_create_template_file_with_nested_replace_patch_inputs(mock_yaml_dump, p "PatchInputs": [ { "op": "replace", - "path": "Property8/Nested/PropertyA", + "path": "/Property8/Nested/PropertyA", "value": update_value_1, }, { "op": "replace", - "path": "Property8/Nested/PropertyB", + "path": "/Property8/Nested/PropertyB", "value": ["{{region}}", update_value_2], }, ], @@ -3657,12 +3640,12 @@ def test_create_template_file_with_nested_remove_patch_inputs(mock_yaml_dump, pr "PatchInputs": [ { "op": "replace", - "path": "Property8/Nested/PropertyA", + "path": "/Property8/Nested/PropertyA", "value": update_value_1, }, { "op": "remove", - "path": "Property8/Nested/PropertyB/1", + "path": "/Property8/Nested/PropertyB/1", }, ], } @@ -3760,7 +3743,7 @@ def test_create_template_file_with_nested_add_patch_inputs(mock_yaml_dump, proje "PatchInputs": [ { "op": "add", - "path": "Property8/Nested/PropertyB/2", + "path": "/Property8/Nested/PropertyB/2", "value": update_value_2, }, ],