diff --git a/docs/_source/docs/stack_config.rst b/docs/_source/docs/stack_config.rst index d8e88075a..52df6d88a 100644 --- a/docs/_source/docs/stack_config.rst +++ b/docs/_source/docs/stack_config.rst @@ -289,6 +289,13 @@ values/resolvers. Lists of values/resolvers will be formatted into an AWS compatible comma separated string e.g. \ ``value1,value2,value3``. Lists can contain a mixture of values and resolvers. +A parameter can also be configured to use the previous value. You can do so by +making the value a dictionary. The values supported in the dictionary are +``initial_value`` and ``use_previous_value``. When creating stacks, setting +``initial_value`` is required, but can be left out for stack updates. The value +set at ``initial_value`` value will only be used during creation, or when +setting ``use_previous_value`` to false. + Syntax: .. code-block:: yaml @@ -305,6 +312,14 @@ Syntax: : - ! - "value1" + : + initial_value: "value" + use_previous_value: + : + initial_value: + - "value1" + - ! + use_previous_value: Example: @@ -320,6 +335,11 @@ Example: - "sg-12345678" - !stack_output security-groups.yaml::BaseSecurityGroupId - !file_contents /file/with/security_group_id.txt + security_group_whitelist: + initial_value: + - "127.0.0.0/24" + - "127.0.1.0/24" + use_previous_value: true parameters_inheritance ~~~~~~~~~~~~~~~~~~~~~~~~ diff --git a/sceptre/exceptions.py b/sceptre/exceptions.py index a0ebce583..6ea076c08 100644 --- a/sceptre/exceptions.py +++ b/sceptre/exceptions.py @@ -191,6 +191,14 @@ class InvalidAWSCredentialsError(SceptreException): pass +class InvalidParameterError(SceptreException): + """ + Error raised when parameters are invalid. + """ + + pass + + class TemplateHandlerNotFoundError(SceptreException): """ Error raised when a Template Handler of a certain type is not found diff --git a/sceptre/plan/actions.py b/sceptre/plan/actions.py index 7b7fd5718..859518d48 100644 --- a/sceptre/plan/actions.py +++ b/sceptre/plan/actions.py @@ -26,6 +26,7 @@ StackDoesNotExistError, UnknownStackChangeSetStatusError, UnknownStackStatusError, + InvalidParameterError, ) from sceptre.helpers import extract_datetime_from_aws_response_headers from sceptre.hooks import add_stack_hooks, add_stack_hooks_with_aliases @@ -71,7 +72,7 @@ def create(self): self.logger.info("%s - Creating Stack", self.stack.name) create_stack_kwargs = { "StackName": self.stack.external_name, - "Parameters": self._format_parameters(self.stack.parameters), + "Parameters": self._format_parameters(self.stack.parameters, create=True), "Capabilities": [ "CAPABILITY_IAM", "CAPABILITY_NAMED_IAM", @@ -759,12 +760,15 @@ def get_status(self): except StackDoesNotExistError: return "PENDING" - def _format_parameters(self, parameters): + def _format_parameters(self, parameters, create=False): """ Converts CloudFormation parameters to the format used by Boto3. :param parameters: A dictionary of parameters. :type parameters: dict + :param create: Flags if this is a stack create or update operation. + :type parameters: bool + :returns: A list of the formatted parameters. :rtype: list """ @@ -772,9 +776,36 @@ def _format_parameters(self, parameters): for name, value in parameters.items(): if value is None: continue + + formatted_parameter = dict(ParameterKey=name) + if isinstance(value, list): - value = ",".join(value) - formatted_parameters.append({"ParameterKey": name, "ParameterValue": value}) + formatted_parameter["ParameterValue"] = ",".join(value) + elif isinstance(value, dict): + initial_value = value.get("initial_value") + use_previous_value = value.get("use_previous_value", False) + if not isinstance(use_previous_value, bool): + raise InvalidParameterError( + "'use_previous_value' must be a boolean" + ) + if create or not use_previous_value: + if initial_value is None: + raise InvalidParameterError( + "'initial_value' is required when creating a new " + "stack or when 'use_previous_value' is false" + ) + elif isinstance(initial_value, list): + formatted_parameter["ParameterValue"] = ",".join(initial_value) + else: + formatted_parameter["ParameterValue"] = value.get( + "initial_value" + ) + else: + formatted_parameter["UsePreviousValue"] = use_previous_value + else: + formatted_parameter["ParameterValue"] = value + + formatted_parameters.append(formatted_parameter) return formatted_parameters diff --git a/tests/test_actions.py b/tests/test_actions.py index 9e66b7601..1e2fb95a9 100644 --- a/tests/test_actions.py +++ b/tests/test_actions.py @@ -14,6 +14,7 @@ StackDoesNotExistError, UnknownStackChangeSetStatusError, UnknownStackStatusError, + InvalidParameterError, ) from sceptre.plan.actions import StackActions from sceptre.stack import Stack @@ -860,6 +861,16 @@ def test_format_parameters_with_none_values(self): ) assert sorted_formatted_parameters == [] + def test_format_parameters_with_empty_dict_value(self): + parameter = {"key": dict()} + with pytest.raises(InvalidParameterError): + self.actions._format_parameters(parameter) + + def test_format_parameters_with_non_bool_previous_value(self): + parameter = {"key": dict(use_previous_value="fosho")} + with pytest.raises(InvalidParameterError): + self.actions._format_parameters(parameter) + def test_format_parameters_with_none_and_string_values(self): parameters = {"key1": "value1", "key2": None, "key3": "value3"} formatted_parameters = self.actions._format_parameters(parameters) @@ -933,6 +944,70 @@ def test_format_parameters_with_none_list_and_string_values(self): {"ParameterKey": "key2", "ParameterValue": "value4"}, ] + def test_format_parameters_with_string_and_dict_values(self): + parameters = {"key1": "value1", "key2": {"initial_value": "value2"}} + formatted_parameters = self.actions._format_parameters(parameters) + sorted_formatted_parameters = sorted( + formatted_parameters, key=lambda x: x["ParameterKey"] + ) + assert sorted_formatted_parameters == [ + {"ParameterKey": "key1", "ParameterValue": "value1"}, + {"ParameterKey": "key2", "ParameterValue": "value2"}, + ] + + def test_format_parameters_with_dict_string_and_list_values(self): + parameters = { + "key1": {"initial_value": ["value1", "value2"]}, + "key2": {"initial_value": "value3"}, + } + formatted_parameters = self.actions._format_parameters(parameters) + sorted_formatted_parameters = sorted( + formatted_parameters, key=lambda x: x["ParameterKey"] + ) + assert sorted_formatted_parameters == [ + {"ParameterKey": "key1", "ParameterValue": "value1,value2"}, + {"ParameterKey": "key2", "ParameterValue": "value3"}, + ] + + def test_format_parameters_with_string_and_previous_values(self): + parameters = { + "key1": "value1", + "key2": {"use_previous_value": True}, + "key3": {"initial_value": "value3", "use_previous_value": True}, + } + formatted_parameters = self.actions._format_parameters(parameters) + sorted_formatted_parameters = sorted( + formatted_parameters, key=lambda x: x["ParameterKey"] + ) + assert sorted_formatted_parameters == [ + {"ParameterKey": "key1", "ParameterValue": "value1"}, + {"ParameterKey": "key2", "UsePreviousValue": True}, + {"ParameterKey": "key3", "UsePreviousValue": True}, + ] + + def test_format_parameters_with_create_and_previous_without_initial_values(self): + parameters = { + "key1": "value1", + "key2": {"use_previous_value": True}, + "key3": {"initial_value": "value3", "use_previous_value": True}, + } + with pytest.raises(InvalidParameterError): + self.actions._format_parameters(parameters, create=True) + + def test_format_parameters_with_create_and_previous_values(self): + parameters = { + "key1": "value1", + "key2": {"initial_value": "value2", "use_previous_value": True}, + } + formatted_parameters = self.actions._format_parameters(parameters, create=True) + sorted_formatted_parameters = sorted( + formatted_parameters, key=lambda x: x["ParameterKey"] + ) + assert sorted_formatted_parameters == [ + {"ParameterKey": "key1", "ParameterValue": "value1"}, + {"ParameterKey": "key2", "ParameterValue": "value2"}, + ] + @patch("sceptre.plan.actions.StackActions.describe") def test_get_status_with_created_stack(self, mock_describe): mock_describe.return_value = {"Stacks": [{"StackStatus": "CREATE_COMPLETE"}]}