Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: consider parameter overrides during sam sync #6426

Merged
merged 8 commits into from
Dec 12, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 78 additions & 2 deletions samcli/lib/sync/infra_sync_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
"""
Expand All @@ -236,13 +238,17 @@ 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
-------
bool
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)

Expand Down Expand Up @@ -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, {})
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
56 changes: 56 additions & 0 deletions tests/unit/lib/sync/test_infra_sync_executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading