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