From 99e6b99e13e020ac777d2dd2765cf717e5b01afb Mon Sep 17 00:00:00 2001 From: "Eric J. Holmes" Date: Fri, 16 Mar 2018 02:55:32 -0700 Subject: [PATCH] Add support for stack policies --- docs/config.rst | 5 +++ stacker/actions/build.py | 15 +++++-- stacker/config/__init__.py | 2 + stacker/providers/aws/default.py | 44 ++++++++++++++++++--- stacker/stack.py | 10 +++++ stacker/tests/fixtures/mock_blueprints.py | 1 + stacker/tests/providers/aws/test_default.py | 8 ++++ tests/fixtures/stack_policies/default.json | 10 +++++ tests/fixtures/stack_policies/none.json | 10 +++++ tests/suite.bats | 1 + 10 files changed, 98 insertions(+), 8 deletions(-) create mode 100644 tests/fixtures/stack_policies/default.json create mode 100644 tests/fixtures/stack_policies/none.json diff --git a/docs/config.rst b/docs/config.rst index 0848a460b..58b7990f6 100644 --- a/docs/config.rst +++ b/docs/config.rst @@ -367,6 +367,11 @@ A stack has the following keys: (optional): If provided, specifies the name of a AWS profile to use when performing AWS API calls for this stack. This can be used to provision stacks in multiple accounts or regions. +**stack_policy_path**: + (optional): If provided, specifies the path to a JSON formatted stack policy + that will be applied when the CloudFormation stack is created and updated. + You can use stack policies to prevent CloudFormation from making updates to + protected resources (e.g. databases). See: https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/protect-stack-resources.html Here's an example from stacker_blueprints_, used to create a VPC:: diff --git a/stacker/actions/build.py b/stacker/actions/build.py index 911bc4a7e..a4a3986ca 100644 --- a/stacker/actions/build.py +++ b/stacker/actions/build.py @@ -280,6 +280,7 @@ def _launch_stack(self, stack, **kwargs): logger.debug("Launching stack %s now.", stack.fqn) template = self._template(stack.blueprint) + stack_policy = self._stack_policy(stack) tags = build_stack_tags(stack) parameters = self.build_parameters(stack, provider_stack) force_change_set = stack.blueprint.requires_change_set @@ -287,12 +288,13 @@ def _launch_stack(self, stack, **kwargs): if recreate: logger.debug("Re-creating stack: %s", stack.fqn) provider.create_stack(stack.fqn, template, parameters, - tags) + tags, stack_policy=stack_policy) return SubmittedStatus("re-creating stack") elif not provider_stack: logger.debug("Creating new stack: %s", stack.fqn) provider.create_stack(stack.fqn, template, parameters, tags, - force_change_set) + force_change_set, + stack_policy=stack_policy) return SubmittedStatus("creating new stack") try: @@ -305,7 +307,8 @@ def _launch_stack(self, stack, **kwargs): parameters, tags, force_interactive=stack.protected, - force_change_set=force_change_set + force_change_set=force_change_set, + stack_policy=stack_policy, ) logger.debug("Updating existing stack: %s", stack.fqn) @@ -332,6 +335,12 @@ def _template(self, blueprint): else: return Template(body=blueprint.rendered) + def _stack_policy(self, stack): + """Returns a Template object for the stacks stack policy, or None if + the stack doesn't have a stack policy.""" + if stack.stack_policy: + return Template(body=stack.stack_policy) + def _generate_plan(self, tail=False): return plan( description="Create/Update stacks", diff --git a/stacker/config/__init__.py b/stacker/config/__init__.py index a37b9e6d3..7a954b49c 100644 --- a/stacker/config/__init__.py +++ b/stacker/config/__init__.py @@ -297,6 +297,8 @@ class Stack(Model): tags = DictType(StringType, serialize_when_none=False) + stack_policy_path = StringType(serialize_when_none=False) + def validate_class_path(self, data, value): if value and data["template_path"]: raise ValidationError( diff --git a/stacker/providers/aws/default.py b/stacker/providers/aws/default.py index 855080beb..a40b16487 100644 --- a/stacker/providers/aws/default.py +++ b/stacker/providers/aws/default.py @@ -366,6 +366,7 @@ def generate_cloudformation_args(stack_name, parameters, tags, template, capabilities=DEFAULT_CAPABILITIES, change_set_type=None, service_role=None, + stack_policy=None, change_set_name=None): """Used to generate the args for common cloudformation API interactions. @@ -414,6 +415,28 @@ def generate_cloudformation_args(stack_name, parameters, tags, template, else: args["TemplateBody"] = template.body + # When creating args for CreateChangeSet, don't include the stack policy, + # since ChangeSets don't support it. + if not change_set_name: + args.update(generate_stack_policy_args(stack_policy)) + + return args + + +def generate_stack_policy_args(stack_policy=None): + args = {} + if stack_policy: + logger.debug("Stack has a stack policy") + if stack_policy.url: + # stacker currently does not support uploading stack policies to + # S3, so this will never get hit (unless your implementing S3 + # uploads, and then you're probably reading this comment about why + # the exception below was raised :)) + # + # args["StackPolicyURL"] = stack_policy.url + raise NotImplementedError + else: + args["StackPolicyBody"] = stack_policy.body return args @@ -600,7 +623,8 @@ def destroy_stack(self, stack, **kwargs): return True def create_stack(self, fqn, template, parameters, tags, - force_change_set=False, **kwargs): + force_change_set=False, stack_policy=None, + **kwargs): """Create a new Cloudformation stack. Args: @@ -637,6 +661,7 @@ def create_stack(self, fqn, template, parameters, tags, args = generate_cloudformation_args( fqn, parameters, tags, template, service_role=self.service_role, + stack_policy=stack_policy, ) try: @@ -739,7 +764,7 @@ def prepare_stack_for_update(self, stack, tags): def update_stack(self, fqn, template, old_parameters, parameters, tags, force_interactive=False, force_change_set=False, - **kwargs): + stack_policy=None, **kwargs): """Update a Cloudformation stack. Args: @@ -770,10 +795,11 @@ def update_stack(self, fqn, template, old_parameters, parameters, tags, force_change_set) return update_method(fqn, template, old_parameters, parameters, tags, - **kwargs) + stack_policy=stack_policy, **kwargs) def interactive_update_stack(self, fqn, template, old_parameters, - parameters, tags, **kwargs): + parameters, tags, stack_policy=None, + **kwargs): """Update a Cloudformation stack in interactive mode. Args: @@ -814,6 +840,13 @@ def interactive_update_stack(self, fqn, template, old_parameters, finally: ui.unlock() + # ChangeSets don't support specifying a stack policy inline, like + # CreateStack/UpdateStack, so we just SetStackPolicy if there is one. + if stack_policy: + args = generate_stack_policy_args(stack_policy) + args["StackName"] = fqn + self.cloudformation.set_stack_policy(args) + self.cloudformation.execute_change_set( ChangeSetName=change_set_id, ) @@ -848,7 +881,7 @@ def noninteractive_changeset_update(self, fqn, template, old_parameters, ) def default_update_stack(self, fqn, template, old_parameters, parameters, - tags, **kwargs): + tags, stack_policy=None, **kwargs): """Update a Cloudformation stack in default mode. Args: @@ -867,6 +900,7 @@ def default_update_stack(self, fqn, template, old_parameters, parameters, args = generate_cloudformation_args( fqn, parameters, tags, template, service_role=self.service_role, + stack_policy=stack_policy, ) try: diff --git a/stacker/stack.py b/stacker/stack.py index bcb59fad4..3d7869bb7 100644 --- a/stacker/stack.py +++ b/stacker/stack.py @@ -102,6 +102,16 @@ def requires(self): return requires + @property + def stack_policy(self): + if not hasattr(self, "_stack_policy"): + self._stack_policy = None + if self.definition.stack_policy_path: + with open(self.definition.stack_policy_path) as f: + self._stack_policy = f.read() + + return self._stack_policy + @property def blueprint(self): if not hasattr(self, "_blueprint"): diff --git a/stacker/tests/fixtures/mock_blueprints.py b/stacker/tests/fixtures/mock_blueprints.py index 73000980c..fd0617f24 100644 --- a/stacker/tests/fixtures/mock_blueprints.py +++ b/stacker/tests/fixtures/mock_blueprints.py @@ -92,6 +92,7 @@ def create_template(self): awacs.cloudformation.DeleteStack, awacs.cloudformation.CreateStack, awacs.cloudformation.UpdateStack, + awacs.cloudformation.SetStackPolicy, awacs.cloudformation.DescribeStacks, awacs.cloudformation.DescribeStackEvents])])) diff --git a/stacker/tests/providers/aws/test_default.py b/stacker/tests/providers/aws/test_default.py index ee6a9134f..2e1d2b267 100644 --- a/stacker/tests/providers/aws/test_default.py +++ b/stacker/tests/providers/aws/test_default.py @@ -348,6 +348,14 @@ def test_generate_cloudformation_args(self): change_set_result["ChangeSetName"] = "MyChanges" self.assertEqual(result, change_set_result) + # Check stack policy + stack_policy = Template(body="{}") + result = generate_cloudformation_args(stack_policy=stack_policy, + **std_args) + stack_policy_result = copy.deepcopy(std_return) + stack_policy_result["StackPolicyBody"] = "{}" + self.assertEqual(result, stack_policy_result) + # If not TemplateURL is provided, use TemplateBody std_args["template"] = Template(body=template_body) template_body_result = copy.deepcopy(std_return) diff --git a/tests/fixtures/stack_policies/default.json b/tests/fixtures/stack_policies/default.json new file mode 100644 index 000000000..57ff589ee --- /dev/null +++ b/tests/fixtures/stack_policies/default.json @@ -0,0 +1,10 @@ +{ + "Statement" : [ + { + "Effect" : "Allow", + "Action" : "Update:*", + "Principal": "*", + "Resource" : "*" + } + ] +} diff --git a/tests/fixtures/stack_policies/none.json b/tests/fixtures/stack_policies/none.json new file mode 100644 index 000000000..2d95bc98c --- /dev/null +++ b/tests/fixtures/stack_policies/none.json @@ -0,0 +1,10 @@ +{ + "Statement" : [ + { + "Effect" : "Deny", + "Action" : "Update:*", + "Principal": "*", + "Resource" : "*" + } + ] +} diff --git a/tests/suite.bats b/tests/suite.bats index ab4143703..3cc2fbb34 100755 --- a/tests/suite.bats +++ b/tests/suite.bats @@ -94,6 +94,7 @@ namespace: ${STACKER_NAMESPACE} stacks: - name: vpc class_path: stacker.tests.fixtures.mock_blueprints.VPC + stack_policy: tests/fixtures/stack_policies/default.json variables: PublicSubnets: 10.128.0.0/24,10.128.1.0/24,10.128.2.0/24,10.128.3.0/24 PrivateSubnets: 10.128.8.0/22,10.128.12.0/22,10.128.16.0/22,10.128.20.0/22