diff --git a/samcli/lib/sync/infra_sync_executor.py b/samcli/lib/sync/infra_sync_executor.py index 257194a70a..e5c48d44dd 100644 --- a/samcli/lib/sync/infra_sync_executor.py +++ b/samcli/lib/sync/infra_sync_executor.py @@ -6,7 +6,7 @@ import re from datetime import datetime from pathlib import Path -from typing import TYPE_CHECKING, Dict, Optional, Set +from typing import TYPE_CHECKING, Dict, List, Optional, Set, cast from uuid import uuid4 from boto3 import Session @@ -183,6 +183,7 @@ def execute_infra_sync(self, first_sync: bool = False) -> InfraSyncResult: self._package_context.output_template_file, self._package_context.template_file, self._deploy_context.stack_name, + self._build_context._parameter_overrides or {}, ): # We have a threshold on number of sync flows we initiate # If higher than the threshold, we perform infra sync to improve performance @@ -221,6 +222,7 @@ def _auto_skip_infra_sync( packaged_template_path: str, built_template_path: str, stack_name: str, + parameter_overrides: Optional[Dict[str, str]] = None, nested_prefix: Optional[str] = None, ) -> bool: """ @@ -236,6 +238,8 @@ def _auto_skip_infra_sync( The CloudFormation stack name that the template is deployed to nested_prefix: Optional[str] The nested stack stack name tree for child stack resources + parameter_overrides: Optional[Dict[str,str]] + Parameter overrides passed into sam sync in the form of { KEY1 : VALUE1, KEY2 : VALUE2 } Returns ------- @@ -243,6 +247,8 @@ def _auto_skip_infra_sync( Returns True if no template changes from last deployment Returns False if there are template differences """ + parameter_overrides = parameter_overrides or {} + current_template = self.get_template(packaged_template_path) current_built_template = self.get_template(built_template_path) @@ -272,6 +278,9 @@ def _auto_skip_infra_sync( LOG.debug("The current template is different from the last deployed version, we will not skip infra sync") return False + if not self._param_overrides_subset_of_stack_params(stack_name, parameter_overrides): + return False + # The recursive template check for Nested stacks for resource_logical_id in current_template.get("Resources", {}): resource_dict = current_template.get("Resources", {}).get(resource_logical_id, {}) @@ -327,7 +336,10 @@ def _auto_skip_infra_sync( resource_dict.get("Properties", {}).get(template_field), nested_template_location, stack_resource_detail.get("StackResourceDetail", {}).get("PhysicalResourceId", ""), - nested_prefix + resource_logical_id + "/" if nested_prefix else resource_logical_id + "/", + parameter_overrides={}, # Do not pass the same parameter overrides to the nested stack + nested_prefix=nested_prefix + resource_logical_id + "/" + if nested_prefix + else resource_logical_id + "/", ): return False @@ -502,6 +514,70 @@ def get_template(self, template_path: str) -> Optional[Dict]: return template + def _param_overrides_subset_of_stack_params(self, stack_name: str, param_overrides: Dict[str, str]) -> bool: + """ + Returns whether or not the supplied parameter overrides are a subset of the current stack parameters + + Parameters + ---------- + stack_name: str + + param_overrides: Dict[str, str] + Parameter overrides supplied by the sam sync command, taking the following format + e.g. {'Foo1': 'Bar1', 'Foo2': 'Bar2'} + + """ + + # Current stack parameters returned from describe_stacks, taking the following format + # e.g [{'ParameterKey': 'Foo1', 'ParameterValue': 'Bar1'}, {'ParameterKey': 'Foo2', 'ParameterValue': 'Bar2'}] + + try: + current_stack_params = self._get_stack_parameters(stack_name) + except ClientError as ex: + LOG.debug("Unable to fetch stack Parameters from stack with name %s", stack_name, exc_info=ex) + return False + + # We can flatten the current stack parameters into the same format as the parameter overrides + # This allows us to check if the parameter overrides are a direct subset of the current stack parameters + + flat_current_stack_parameters = {} + for param in current_stack_params: + flat_current_stack_parameters[param["ParameterKey"]] = param["ParameterValue"] + + # Check for parameter overrides being a subset of the current stack parameters + if not (param_overrides.items() <= flat_current_stack_parameters.items()): + LOG.debug("Detected changes between Parameter overrides and the current stack parameters.") + return False + + return True + + def _get_stack_parameters(self, stack_name: str) -> List[Dict[str, str]]: + """ + Returns the stack parameters for a given stack + + Parameters + ---------- + stack_name: str + The name of the stack + + Returns + ------- + List of Dicts in the form { 'ParameterKey': Foo, 'ParameterValue': Bar } + + """ + stacks = self._cfn_client.describe_stacks(StackName=stack_name).get("Stacks") + + if len(stacks) < 1: + LOG.info( + "Failed to pull stack details for stack with name %s, it may not yet be finished deploying.", stack_name + ) + return [] + + return cast( + List[Dict[str, str]], + stacks[0].get("Parameters", []), + ) + def _get_remote_template_data(self, template_path: str) -> Optional[Dict]: """ Get template dict from remote location diff --git a/tests/unit/lib/sync/test_infra_sync_executor.py b/tests/unit/lib/sync/test_infra_sync_executor.py index 9142df482a..9c41dbeb65 100644 --- a/tests/unit/lib/sync/test_infra_sync_executor.py +++ b/tests/unit/lib/sync/test_infra_sync_executor.py @@ -518,6 +518,62 @@ def test_auto_skip_infra_sync_exception(self, session_mock, get_template_mock, l self.assertFalse(infra_sync_executor._auto_skip_infra_sync("path", "path2", "stack_name")) + # Test stack parameters are { 'Foo' : 'Bar', 'Apples' : 'Oranges' } + @parameterized.expand( + [ + ({"Foo": "Bar"}, True), # Subset + ({"Foo": "Bar", "Apples": "Oranges"}, True), # Equal + ({"Apples": "Oranges", "Foo": "Bar"}, True), # Equal, different order + ({"Foo": "Bar", "Apples": "Grapes"}, False), # One pair matches the other does not + ( + {"Foo": "Bar", "Apples": "Oranges", "Red": "Blue"}, + False, + ), # Overrides is a superset of current parameters + ] + ) + @patch("samcli.lib.sync.infra_sync_executor.is_local_path") + @patch("samcli.lib.sync.infra_sync_executor.get_template_data") + @patch("samcli.lib.sync.infra_sync_executor.Session") + def test_auto_skip_infra_sync_param_overrides( + self, param_overrides, expect_skip_infra_sync, session_mock, get_template_mock, local_path_mock + ): + built_template_dict = { + "Resources": { + "ServerlessFunction": {"Type": "AWS::Serverless::Function", "Properties": {"CodeUri": "local/"}} + } + } + packaged_template_dict = { + "Resources": { + "ServerlessFunction": {"Type": "AWS::Serverless::Function", "Properties": {"CodeUri": "https://s3_new"}} + } + } + + get_template_mock.side_effect = [packaged_template_dict, built_template_dict] + local_path_mock.return_value = True + + infra_sync_executor = InfraSyncExecutor( + self.build_context, self.package_context, self.deploy_context, self.sync_context + ) + infra_sync_executor._cfn_client.get_template.return_value = { + "TemplateBody": """{ + "Resources": { + "ServerlessFunction": {"Type": "AWS::Serverless::Function", "Properties": {"CodeUri": "https://s3"}} + } + }""" + } + + infra_sync_executor._get_stack_parameters = MagicMock() + + infra_sync_executor._get_stack_parameters.return_value = [ + {"ParameterKey": "Foo", "ParameterValue": "Bar"}, + {"ParameterKey": "Apples", "ParameterValue": "Oranges"}, + ] + + self.assertEqual( + infra_sync_executor._auto_skip_infra_sync("path", "path2", "stack_name", param_overrides), + expect_skip_infra_sync, + ) + @patch("samcli.lib.sync.infra_sync_executor.is_local_path") @patch("samcli.lib.sync.infra_sync_executor.Session") def test_sanitize_template(self, session_mock, local_path_mock):