diff --git a/runway/cfngin/providers/aws/default.py b/runway/cfngin/providers/aws/default.py index 315326e14..28f4340ef 100644 --- a/runway/cfngin/providers/aws/default.py +++ b/runway/cfngin/providers/aws/default.py @@ -36,6 +36,7 @@ from mypy_boto3_cloudformation.client import CloudFormationClient from mypy_boto3_cloudformation.type_defs import ( ChangeTypeDef, + DeleteStackInputRequestTypeDef, DescribeChangeSetOutputTypeDef, ParameterTypeDef, StackEventTypeDef, @@ -1184,7 +1185,9 @@ def interactive_destroy_stack( raise exceptions.CancelExecution try: - return self.noninteractive_destroy_stack(fqn, **kwargs) + return self.noninteractive_destroy_stack( + fqn, allow_disable_termination_protection=False, **kwargs + ) except botocore.exceptions.ClientError as err: if "TerminationProtection" in err.response["Error"]["Message"]: approval = ui.ask( @@ -1272,19 +1275,36 @@ def interactive_update_stack( self.cloudformation.execute_change_set(ChangeSetName=change_set_id) - def noninteractive_destroy_stack(self, fqn: str, **_kwargs: Any) -> None: + def noninteractive_destroy_stack( + self, fqn: str, *, allow_disable_termination_protection: bool = True, **_kwargs: Any + ) -> None: """Delete a CloudFormation stack without interaction. Args: fqn: A fully qualified stack name. + allow_disable_termination_protection: Whether to disable termination protection + if deletion fails do to it being enable. + Termination protection will only be disable if the Stack's state + would typically allow for recreation. """ LOGGER.debug("%s:destroying stack", fqn) - args = {"StackName": fqn} + args: DeleteStackInputRequestTypeDef = {"StackName": fqn} if self.service_role: args["RoleARN"] = self.service_role - self.cloudformation.delete_stack(**args) # pyright: ignore[reportArgumentType] + try: + self.cloudformation.delete_stack(**args) + except botocore.exceptions.ClientError as exc: + if ( + allow_disable_termination_protection + and "TerminationProtection" in exc.response["Error"]["Message"] + and self.is_stack_recreatable(self.get_stack(fqn)) + ): + self.update_termination_protection(fqn, False) + self.cloudformation.delete_stack(**args) + else: + raise def noninteractive_changeset_update( self, diff --git a/tests/unit/cfngin/providers/aws/test_default.py b/tests/unit/cfngin/providers/aws/test_default.py index a90ff5aaf..ad9e94487 100644 --- a/tests/unit/cfngin/providers/aws/test_default.py +++ b/tests/unit/cfngin/providers/aws/test_default.py @@ -793,12 +793,42 @@ def test_noninteractive_changeset_update_with_stack_policy(self) -> None: def test_noninteractive_destroy_stack_termination_protected(self) -> None: """Test noninteractive_destroy_stack with termination protection.""" - self.stubber.add_client_error("delete_stack") + self.stubber.add_client_error("delete_stack", service_message="TerminationProtection") + self.stubber.add_response( + "describe_stacks", + { + "Stacks": [ + generate_describe_stacks_stack("fake-stack", stack_status="ROLLBACK_COMPLETE") + ] + }, + ) + self.stubber.add_response( + "describe_stacks", + {"Stacks": [generate_describe_stacks_stack("fake-stack", termination_protection=True)]}, + ) + self.stubber.add_response( + "update_termination_protection", + {}, + {"EnableTerminationProtection": False, "StackName": "fake-stack"}, + ) + self.stubber.add_response("delete_stack", {}, {"StackName": "fake-stack"}) - with self.stubber, pytest.raises(ClientError): + with self.stubber: self.provider.noninteractive_destroy_stack("fake-stack") self.stubber.assert_no_pending_responses() + def test_noninteractive_destroy_stack_termination_protected_not_allowed( + self, + ) -> None: + """Test noninteractive_destroy_stack with termination protection.""" + self.stubber.add_client_error("delete_stack", service_message="TerminationProtection") + + with self.stubber, pytest.raises(ClientError): + self.provider.noninteractive_destroy_stack( + "fake-stack", allow_disable_termination_protection=False + ) + self.stubber.assert_no_pending_responses() + @patch("runway.cfngin.providers.aws.default.output_full_changeset") def test_get_stack_changes_update(self, mock_output_full_cs: MagicMock) -> None: """Test get stack changes update."""