Example #1
0
class TestProviderDefaultMode(unittest.TestCase):
    """Tests for runway.cfngin.providers.aws.default default mode."""
    def setUp(self):
        """Run before tests."""
        region = "us-east-1"
        self.session = get_session(region=region)
        self.provider = Provider(self.session,
                                 region=region,
                                 recreate_failed=False)
        self.stubber = Stubber(self.provider.cloudformation)

    def test_destroy_stack(self):
        """Test destroy stack."""
        stack = {'StackName': 'MockStack'}

        self.stubber.add_response('delete_stack', {}, stack)

        with self.stubber:
            self.assertIsNone(self.provider.destroy_stack(stack))
            self.stubber.assert_no_pending_responses()

    def test_get_stack_stack_does_not_exist(self):
        """Test get stack stack does not exist."""
        stack_name = "MockStack"
        self.stubber.add_client_error(
            "describe_stacks",
            service_error_code="ValidationError",
            service_message="Stack with id %s does not exist" % stack_name,
            expected_params={"StackName": stack_name})

        with self.assertRaises(exceptions.StackDoesNotExist):
            with self.stubber:
                self.provider.get_stack(stack_name)

    def test_get_stack_stack_exists(self):
        """Test get stack stack exists."""
        stack_name = "MockStack"
        stack_response = {
            "Stacks": [generate_describe_stacks_stack(stack_name)]
        }
        self.stubber.add_response("describe_stacks",
                                  stack_response,
                                  expected_params={"StackName": stack_name})

        with self.stubber:
            response = self.provider.get_stack(stack_name)

        self.assertEqual(response["StackName"], stack_name)

    def test_select_destroy_method(self):
        """Test select destroy method."""
        for i in [[{
                'force_interactive': False
        }, self.provider.noninteractive_destroy_stack],
                  [{
                      'force_interactive': True
                  }, self.provider.interactive_destroy_stack]]:
            self.assertEqual(self.provider.select_destroy_method(**i[0]), i[1])

    def test_select_update_method(self):
        """Test select update method."""
        for i in [[{
                'force_interactive': True,
                'force_change_set': False
        }, self.provider.interactive_update_stack],
                  [{
                      'force_interactive': False,
                      'force_change_set': False
                  }, self.provider.default_update_stack],
                  [{
                      'force_interactive': False,
                      'force_change_set': True
                  }, self.provider.noninteractive_changeset_update],
                  [{
                      'force_interactive': True,
                      'force_change_set': True
                  }, self.provider.interactive_update_stack]]:
            self.assertEqual(self.provider.select_update_method(**i[0]), i[1])

    def test_prepare_stack_for_update_completed(self):
        """Test prepare stack for update completed."""
        stack_name = "MockStack"
        stack = generate_describe_stacks_stack(stack_name,
                                               stack_status="UPDATE_COMPLETE")

        with self.stubber:
            self.assertTrue(self.provider.prepare_stack_for_update(stack, []))

    def test_prepare_stack_for_update_in_progress(self):
        """Test prepare stack for update in progress."""
        stack_name = "MockStack"
        stack = generate_describe_stacks_stack(
            stack_name, stack_status="UPDATE_IN_PROGRESS")

        with self.assertRaises(exceptions.StackUpdateBadStatus) as raised:
            with self.stubber:
                self.provider.prepare_stack_for_update(stack, [])

            self.assertIn('in-progress', str(raised.exception))

    def test_prepare_stack_for_update_non_recreatable(self):
        """Test prepare stack for update non recreatable."""
        stack_name = "MockStack"
        stack = generate_describe_stacks_stack(
            stack_name, stack_status="REVIEW_IN_PROGRESS")

        with self.assertRaises(exceptions.StackUpdateBadStatus) as raised:
            with self.stubber:
                self.provider.prepare_stack_for_update(stack, [])

        self.assertIn('Unsupported state', str(raised.exception))

    def test_prepare_stack_for_update_disallowed(self):
        """Test prepare stack for update disallowed."""
        stack_name = "MockStack"
        stack = generate_describe_stacks_stack(
            stack_name, stack_status="ROLLBACK_COMPLETE")

        with self.assertRaises(exceptions.StackUpdateBadStatus) as raised:
            with self.stubber:
                self.provider.prepare_stack_for_update(stack, [])

        self.assertIn('re-creation is disabled', str(raised.exception))
        # Ensure we point out to the user how to enable re-creation
        self.assertIn('--recreate-failed', str(raised.exception))

    def test_prepare_stack_for_update_bad_tags(self):
        """Test prepare stack for update bad tags."""
        stack_name = "MockStack"
        stack = generate_describe_stacks_stack(
            stack_name, stack_status="ROLLBACK_COMPLETE")

        self.provider.recreate_failed = True

        with self.assertRaises(exceptions.StackUpdateBadStatus) as raised:
            with self.stubber:
                self.provider.prepare_stack_for_update(stack,
                                                       tags=[{
                                                           'Key':
                                                           'cfngin_namespace',
                                                           'Value': 'test'
                                                       }])

        self.assertIn('tags differ', str(raised.exception).lower())

    def test_prepare_stack_for_update_recreate(self):
        """Test prepare stack for update recreate."""
        stack_name = "MockStack"
        stack = generate_describe_stacks_stack(
            stack_name, stack_status="ROLLBACK_COMPLETE")

        self.stubber.add_response("delete_stack", {},
                                  expected_params={"StackName": stack_name})

        self.provider.recreate_failed = True

        with self.stubber:
            self.assertFalse(self.provider.prepare_stack_for_update(stack, []))

    def test_noninteractive_changeset_update_no_stack_policy(self):
        """Test noninteractive changeset update no stack policy."""
        stack_name = "MockStack"

        self.stubber.add_response("create_change_set", {
            'Id': 'CHANGESETID',
            'StackId': 'STACKID'
        })
        changes = []
        changes.append(generate_change())

        self.stubber.add_response(
            "describe_change_set",
            generate_change_set_response(
                status="CREATE_COMPLETE",
                execution_status="AVAILABLE",
                changes=changes,
            ))

        self.stubber.add_response("execute_change_set", {})

        with self.stubber:
            self.provider.noninteractive_changeset_update(
                fqn=stack_name,
                template=Template(url="http://fake.template.url.com/"),
                old_parameters=[],
                parameters=[],
                stack_policy=None,
                tags=[],
            )

    def test_noninteractive_changeset_update_with_stack_policy(self):
        """Test noninteractive changeset update with stack policy."""
        stack_name = "MockStack"

        self.stubber.add_response("create_change_set", {
            'Id': 'CHANGESETID',
            'StackId': 'STACKID'
        })
        changes = []
        changes.append(generate_change())

        self.stubber.add_response(
            "describe_change_set",
            generate_change_set_response(
                status="CREATE_COMPLETE",
                execution_status="AVAILABLE",
                changes=changes,
            ))

        self.stubber.add_response("set_stack_policy", {})

        self.stubber.add_response("execute_change_set", {})

        with self.stubber:
            self.provider.noninteractive_changeset_update(
                fqn=stack_name,
                template=Template(url="http://fake.template.url.com/"),
                old_parameters=[],
                parameters=[],
                stack_policy=Template(body="{}"),
                tags=[],
            )

    @patch('runway.cfngin.providers.aws.default.output_full_changeset')
    def test_get_stack_changes_update(self, mock_output_full_cs):
        """Test get stack changes update."""
        stack_name = "MockStack"
        mock_stack = generate_stack_object(stack_name)

        self.stubber.add_response(
            'describe_stacks',
            {'Stacks': [generate_describe_stacks_stack(stack_name)]})
        self.stubber.add_response('get_template',
                                  generate_get_template('cfn_template.yaml'))
        self.stubber.add_response("create_change_set", {
            'Id': 'CHANGESETID',
            'StackId': stack_name
        })
        changes = []
        changes.append(generate_change())

        self.stubber.add_response(
            "describe_change_set",
            generate_change_set_response(
                status="CREATE_COMPLETE",
                execution_status="AVAILABLE",
                changes=changes,
            ))
        self.stubber.add_response("delete_change_set", {})
        self.stubber.add_response(
            'describe_stacks',
            {'Stacks': [generate_describe_stacks_stack(stack_name)]})

        with self.stubber:
            result = self.provider.get_stack_changes(
                stack=mock_stack,
                template=Template(url="http://fake.template.url.com/"),
                parameters=[],
                tags=[])

        mock_output_full_cs.assert_called_with(full_changeset=changes,
                                               params_diff=[],
                                               fqn=stack_name,
                                               answer='y')
        expected_outputs = {
            'FakeOutput':
            '<inferred-change: MockStack.FakeOutput={}>'.format(
                str({"Ref": "FakeResource"}))
        }
        self.assertEqual(self.provider.get_outputs(stack_name),
                         expected_outputs)
        self.assertEqual(result, expected_outputs)

    @patch('runway.cfngin.providers.aws.default.output_full_changeset')
    def test_get_stack_changes_create(self, mock_output_full_cs):
        """Test get stack changes create."""
        stack_name = "MockStack"
        mock_stack = generate_stack_object(stack_name)

        self.stubber.add_response(
            'describe_stacks', {
                'Stacks': [
                    generate_describe_stacks_stack(
                        stack_name, stack_status='REVIEW_IN_PROGRESS')
                ]
            })
        self.stubber.add_response("create_change_set", {
            'Id': 'CHANGESETID',
            'StackId': stack_name
        })
        changes = []
        changes.append(generate_change())

        self.stubber.add_response(
            "describe_change_set",
            generate_change_set_response(
                status="CREATE_COMPLETE",
                execution_status="AVAILABLE",
                changes=changes,
            ))
        self.stubber.add_response("delete_change_set", {})
        self.stubber.add_response(
            'describe_stacks', {
                'Stacks': [
                    generate_describe_stacks_stack(
                        stack_name, stack_status='REVIEW_IN_PROGRESS')
                ]
            })
        self.stubber.add_response(
            'describe_stacks', {
                'Stacks': [
                    generate_describe_stacks_stack(
                        stack_name, stack_status='REVIEW_IN_PROGRESS')
                ]
            })

        self.stubber.add_response("delete_stack", {})

        with self.stubber:
            self.provider.get_stack_changes(
                stack=mock_stack,
                template=Template(url="http://fake.template.url.com/"),
                parameters=[],
                tags=[])

        mock_output_full_cs.assert_called_with(full_changeset=changes,
                                               params_diff=[],
                                               fqn=stack_name,
                                               answer='y')

    def test_tail_stack_retry_on_missing_stack(self):
        """Test tail stack retry on missing stack."""
        stack_name = "SlowToCreateStack"
        stack = MagicMock(spec=Stack)
        stack.fqn = "my-namespace-{}".format(stack_name)

        default.TAIL_RETRY_SLEEP = .01

        # Ensure the stack never appears before we run out of retries
        for i in range(MAX_TAIL_RETRIES + 5):
            self.stubber.add_client_error(
                "describe_stack_events",
                service_error_code="ValidationError",
                service_message="Stack [{}] does not exist".format(stack_name),
                http_status_code=400,
                response_meta={"attempt": i + 1},
            )

        with self.stubber:
            try:
                self.provider.tail_stack(stack, threading.Event())
            except ClientError as exc:
                self.assertEqual(exc.response["ResponseMetadata"]["attempt"],
                                 MAX_TAIL_RETRIES)

    def test_tail_stack_retry_on_missing_stack_eventual_success(self):
        """Test tail stack retry on missing stack eventual success."""
        stack_name = "SlowToCreateStack"
        stack = MagicMock(spec=Stack)
        stack.fqn = "my-namespace-{}".format(stack_name)

        default.TAIL_RETRY_SLEEP = .01
        default.GET_EVENTS_SLEEP = .01

        received_events = []

        def mock_log_func(event):
            received_events.append(event)

        def valid_event_response(stack, event_id):
            return {
                "StackEvents": [
                    {
                        "StackId": stack.fqn + "12345",
                        "EventId": event_id,
                        "StackName": stack.fqn,
                        "Timestamp": datetime.now()
                    },
                ]
            }

        # Ensure the stack never appears before we run out of retries
        for i in range(3):
            self.stubber.add_client_error(
                "describe_stack_events",
                service_error_code="ValidationError",
                service_message="Stack [{}] does not exist".format(stack_name),
                http_status_code=400,
                response_meta={"attempt": i + 1},
            )

        self.stubber.add_response("describe_stack_events",
                                  valid_event_response(stack, "InitialEvents"))

        self.stubber.add_response("describe_stack_events",
                                  valid_event_response(stack, "Event1"))

        with self.stubber:
            try:
                self.provider.tail_stack(stack,
                                         threading.Event(),
                                         log_func=mock_log_func)
            except UnStubbedResponseError:
                # Eventually we run out of responses - could not happen in
                # regular execution
                # normally this would just be dealt with when the threads were
                # shutdown, but doing so here is a little difficult because
                # we can't control the `tail_stack` loop
                pass

        self.assertEqual(received_events[0]["EventId"], "Event1")
Example #2
0
class TestProviderDefaultMode(unittest.TestCase):
    """Tests for runway.cfngin.providers.aws.default default mode."""

    def setUp(self) -> None:
        """Run before tests."""
        region = "us-east-1"
        self.session = get_session(region=region)
        self.provider = Provider(self.session, region=region, recreate_failed=False)
        self.stubber = Stubber(self.provider.cloudformation)

    def test_create_stack_no_changeset(self) -> None:
        """Test create_stack, no changeset, template url."""
        stack_name = "fake_stack"
        template = Template(url="http://fake.template.url.com/")
        parameters: List[Any] = []
        tags: List[Any] = []

        expected_args = generate_cloudformation_args(
            stack_name, parameters, tags, template
        )
        expected_args["EnableTerminationProtection"] = False
        expected_args["TimeoutInMinutes"] = 60

        self.stubber.add_response(
            "create_stack", {"StackId": stack_name}, expected_args
        )

        with self.stubber:
            self.provider.create_stack(
                stack_name, template, parameters, tags, timeout=60
            )
        self.stubber.assert_no_pending_responses()

    @patch("runway.cfngin.providers.aws.default.Provider.update_termination_protection")
    @patch("runway.cfngin.providers.aws.default.create_change_set")
    def test_create_stack_with_changeset(
        self, patched_create_change_set: MagicMock, patched_update_term: MagicMock
    ) -> None:
        """Test create_stack, force changeset, termination protection."""
        stack_name = "fake_stack"
        template_path = Path("./tests/unit/cfngin/fixtures/cfn_template.yaml")
        template = Template(body=template_path.read_text())
        parameters: List[Any] = []
        tags: List[Any] = []

        changeset_id = "CHANGESETID"

        patched_create_change_set.return_value = ([], changeset_id)

        self.stubber.add_response(
            "execute_change_set", {}, {"ChangeSetName": changeset_id}
        )

        with self.stubber:
            self.provider.create_stack(
                stack_name,
                template,
                parameters,
                tags,
                force_change_set=True,
                termination_protection=True,
            )
        self.stubber.assert_no_pending_responses()

        patched_create_change_set.assert_called_once_with(
            self.provider.cloudformation,
            stack_name,
            template,
            parameters,
            tags,
            "CREATE",
            service_role=self.provider.service_role,
        )
        patched_update_term.assert_called_once_with(stack_name, True)

    def test_destroy_stack(self) -> None:
        """Test destroy stack."""
        stack = {"StackName": "MockStack"}

        self.stubber.add_response("delete_stack", {}, stack)

        with self.stubber:
            self.assertIsNone(self.provider.destroy_stack(stack))  # type: ignore
            self.stubber.assert_no_pending_responses()

    def test_get_stack_stack_does_not_exist(self) -> None:
        """Test get stack stack does not exist."""
        stack_name = "MockStack"
        self.stubber.add_client_error(
            "describe_stacks",
            service_error_code="ValidationError",
            service_message="Stack with id %s does not exist" % stack_name,
            expected_params={"StackName": stack_name},
        )

        with self.assertRaises(exceptions.StackDoesNotExist):
            with self.stubber:
                self.provider.get_stack(stack_name)

    def test_get_stack_stack_exists(self) -> None:
        """Test get stack stack exists."""
        stack_name = "MockStack"
        stack_response = {"Stacks": [generate_describe_stacks_stack(stack_name)]}
        self.stubber.add_response(
            "describe_stacks", stack_response, expected_params={"StackName": stack_name}
        )

        with self.stubber:
            response = self.provider.get_stack(stack_name)

        self.assertEqual(response["StackName"], stack_name)

    def test_select_destroy_method(self) -> None:
        """Test select destroy method."""
        for i in [
            [{"force_interactive": False}, self.provider.noninteractive_destroy_stack],
            [{"force_interactive": True}, self.provider.interactive_destroy_stack],
        ]:
            self.assertEqual(
                self.provider.select_destroy_method(**i[0]), i[1]  # type: ignore
            )

    def test_select_update_method(self) -> None:
        """Test select update method."""
        for i in [
            [
                {"force_interactive": True, "force_change_set": False},
                self.provider.interactive_update_stack,
            ],
            [
                {"force_interactive": False, "force_change_set": False},
                self.provider.default_update_stack,
            ],
            [
                {"force_interactive": False, "force_change_set": True},
                self.provider.noninteractive_changeset_update,
            ],
            [
                {"force_interactive": True, "force_change_set": True},
                self.provider.interactive_update_stack,
            ],
        ]:
            self.assertEqual(
                self.provider.select_update_method(**i[0]), i[1]  # type: ignore
            )

    def test_prepare_stack_for_update_completed(self) -> None:
        """Test prepare stack for update completed."""
        with self.stubber:
            stack_name = "MockStack"
            stack = generate_describe_stacks_stack(
                stack_name, stack_status="UPDATE_COMPLETE"
            )

            self.assertTrue(self.provider.prepare_stack_for_update(stack, []))

    def test_prepare_stack_for_update_in_progress(self) -> None:
        """Test prepare stack for update in progress."""
        stack_name = "MockStack"
        stack = generate_describe_stacks_stack(
            stack_name, stack_status="UPDATE_IN_PROGRESS"
        )

        with self.assertRaises(exceptions.StackUpdateBadStatus) as raised:
            with self.stubber:
                self.provider.prepare_stack_for_update(stack, [])

            self.assertIn("in-progress", str(raised.exception))

    def test_prepare_stack_for_update_non_recreatable(self) -> None:
        """Test prepare stack for update non recreatable."""
        stack_name = "MockStack"
        stack = generate_describe_stacks_stack(
            stack_name, stack_status="REVIEW_IN_PROGRESS"
        )

        with self.assertRaises(exceptions.StackUpdateBadStatus) as raised:
            with self.stubber:
                self.provider.prepare_stack_for_update(stack, [])

        self.assertIn("Unsupported state", str(raised.exception))

    def test_prepare_stack_for_update_disallowed(self) -> None:
        """Test prepare stack for update disallowed."""
        stack_name = "MockStack"
        stack = generate_describe_stacks_stack(
            stack_name, stack_status="ROLLBACK_COMPLETE"
        )

        with self.assertRaises(exceptions.StackUpdateBadStatus) as raised:
            with self.stubber:
                self.provider.prepare_stack_for_update(stack, [])

        self.assertIn("re-creation is disabled", str(raised.exception))
        # Ensure we point out to the user how to enable re-creation
        self.assertIn("--recreate-failed", str(raised.exception))

    def test_prepare_stack_for_update_bad_tags(self) -> None:
        """Test prepare stack for update bad tags."""
        stack_name = "MockStack"
        stack = generate_describe_stacks_stack(
            stack_name, stack_status="ROLLBACK_COMPLETE"
        )

        self.provider.recreate_failed = True

        with self.assertRaises(exceptions.StackUpdateBadStatus) as raised:
            with self.stubber:
                self.provider.prepare_stack_for_update(
                    stack, tags=[{"Key": "cfngin_namespace", "Value": "test"}]
                )

        self.assertIn("tags differ", str(raised.exception).lower())

    def test_prepare_stack_for_update_recreate(self) -> None:
        """Test prepare stack for update recreate."""
        stack_name = "MockStack"
        stack = generate_describe_stacks_stack(
            stack_name, stack_status="ROLLBACK_COMPLETE"
        )

        self.stubber.add_response(
            "delete_stack", {}, expected_params={"StackName": stack_name}
        )

        self.provider.recreate_failed = True

        with self.stubber:
            self.assertFalse(self.provider.prepare_stack_for_update(stack, []))

    def test_noninteractive_changeset_update_no_stack_policy(self) -> None:
        """Test noninteractive changeset update no stack policy."""
        self.stubber.add_response(
            "create_change_set", {"Id": "CHANGESETID", "StackId": "STACKID"}
        )
        changes = [generate_change()]
        self.stubber.add_response(
            "describe_change_set",
            generate_change_set_response(
                status="CREATE_COMPLETE", execution_status="AVAILABLE", changes=changes
            ),
        )

        self.stubber.add_response("execute_change_set", {})

        with self.stubber:
            stack_name = "MockStack"
            self.provider.noninteractive_changeset_update(
                fqn=stack_name,
                template=Template(url="http://fake.template.url.com/"),
                old_parameters=[],
                parameters=[],
                stack_policy=None,
                tags=[],
            )

    def test_noninteractive_changeset_update_with_stack_policy(self) -> None:
        """Test noninteractive changeset update with stack policy."""
        self.stubber.add_response(
            "create_change_set", {"Id": "CHANGESETID", "StackId": "STACKID"}
        )
        changes = [generate_change()]
        self.stubber.add_response(
            "describe_change_set",
            generate_change_set_response(
                status="CREATE_COMPLETE", execution_status="AVAILABLE", changes=changes
            ),
        )
        self.stubber.add_response("set_stack_policy", {})
        self.stubber.add_response("execute_change_set", {})

        with self.stubber:
            stack_name = "MockStack"
            self.provider.noninteractive_changeset_update(
                fqn=stack_name,
                template=Template(url="http://fake.template.url.com/"),
                old_parameters=[],
                parameters=[],
                stack_policy=Template(body="{}"),
                tags=[],
            )

    def test_noninteractive_destroy_stack_termination_protected(self) -> None:
        """Test noninteractive_destroy_stack with termination protection."""
        self.stubber.add_client_error("delete_stack")

        with self.stubber, self.assertRaises(ClientError):
            self.provider.noninteractive_destroy_stack("fake-stack")
        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."""
        stack_name = "MockStack"
        mock_stack = generate_stack_object(stack_name)

        self.stubber.add_response(
            "describe_stacks", {"Stacks": [generate_describe_stacks_stack(stack_name)]}
        )
        self.stubber.add_response(
            "get_template", generate_get_template("cfn_template.yaml")
        )
        self.stubber.add_response(
            "create_change_set", {"Id": "CHANGESETID", "StackId": stack_name}
        )
        changes = [generate_change()]
        self.stubber.add_response(
            "describe_change_set",
            generate_change_set_response(
                status="CREATE_COMPLETE", execution_status="AVAILABLE", changes=changes
            ),
        )
        self.stubber.add_response("delete_change_set", {})
        self.stubber.add_response(
            "describe_stacks", {"Stacks": [generate_describe_stacks_stack(stack_name)]}
        )

        with self.stubber:
            result = self.provider.get_stack_changes(
                stack=mock_stack,
                template=Template(url="http://fake.template.url.com/"),
                parameters=[],
                tags=[],
            )

        mock_output_full_cs.assert_called_with(
            full_changeset=changes, params_diff=[], fqn=stack_name, answer="y"
        )
        expected_outputs = {
            "FakeOutput": "<inferred-change: MockStack.FakeOutput={}>".format(
                str({"Ref": "FakeResource"})
            )
        }
        self.assertEqual(self.provider.get_outputs(stack_name), expected_outputs)
        self.assertEqual(result, expected_outputs)

    @patch("runway.cfngin.providers.aws.default.output_full_changeset")
    def test_get_stack_changes_create(self, mock_output_full_cs: MagicMock) -> None:
        """Test get stack changes create."""
        stack_name = "MockStack"
        mock_stack = generate_stack_object(stack_name)

        self.stubber.add_response(
            "describe_stacks",
            {
                "Stacks": [
                    generate_describe_stacks_stack(
                        stack_name, stack_status="REVIEW_IN_PROGRESS"
                    )
                ]
            },
        )
        self.stubber.add_response(
            "create_change_set", {"Id": "CHANGESETID", "StackId": stack_name}
        )
        changes = [generate_change()]
        self.stubber.add_response(
            "describe_change_set",
            generate_change_set_response(
                status="CREATE_COMPLETE", execution_status="AVAILABLE", changes=changes
            ),
        )
        self.stubber.add_response("delete_change_set", {})
        self.stubber.add_response(
            "describe_stacks",
            {
                "Stacks": [
                    generate_describe_stacks_stack(
                        stack_name, stack_status="REVIEW_IN_PROGRESS"
                    )
                ]
            },
        )
        self.stubber.add_response(
            "describe_stacks",
            {
                "Stacks": [
                    generate_describe_stacks_stack(
                        stack_name, stack_status="REVIEW_IN_PROGRESS"
                    )
                ]
            },
        )

        self.stubber.add_response("delete_stack", {})

        with self.stubber:
            self.provider.get_stack_changes(
                stack=mock_stack,
                template=Template(url="http://fake.template.url.com/"),
                parameters=[],
                tags=[],
            )

        mock_output_full_cs.assert_called_with(
            full_changeset=changes, params_diff=[], fqn=stack_name, answer="y"
        )

    def test_tail_stack_retry_on_missing_stack(self) -> None:
        """Test tail stack retry on missing stack."""
        stack_name = "SlowToCreateStack"
        stack = MagicMock(spec=Stack)
        stack.fqn = "my-namespace-{}".format(stack_name)

        default.TAIL_RETRY_SLEEP = 0.01

        # Ensure the stack never appears before we run out of retries
        for i in range(MAX_TAIL_RETRIES + 5):
            self.stubber.add_client_error(
                "describe_stack_events",
                service_error_code="ValidationError",
                service_message="Stack [{}] does not exist".format(stack_name),
                http_status_code=400,
                response_meta={"attempt": i + 1},
            )

        with self.stubber:
            try:
                self.provider.tail_stack(stack, threading.Event())
            except ClientError as exc:
                self.assertEqual(
                    exc.response["ResponseMetadata"]["attempt"], MAX_TAIL_RETRIES
                )

    def test_tail_stack_retry_on_missing_stack_eventual_success(self) -> None:
        """Test tail stack retry on missing stack eventual success."""
        stack_name = "SlowToCreateStack"
        stack = MagicMock(spec=Stack)
        stack.fqn = "my-namespace-{}".format(stack_name)

        default.TAIL_RETRY_SLEEP = 0.01
        default.GET_EVENTS_SLEEP = 0.01

        received_events: List[Any] = []

        def mock_log_func(event: Any) -> None:
            received_events.append(event)

        def valid_event_response(stack: Stack, event_id: str) -> Dict[str, Any]:
            return {
                "StackEvents": [
                    {
                        "StackId": stack.fqn + "12345",
                        "EventId": event_id,
                        "StackName": stack.fqn,
                        "Timestamp": datetime.now(),
                    },
                ]
            }

        # Ensure the stack never appears before we run out of retries
        for i in range(3):
            self.stubber.add_client_error(
                "describe_stack_events",
                service_error_code="ValidationError",
                service_message="Stack [{}] does not exist".format(stack_name),
                http_status_code=400,
                response_meta={"attempt": i + 1},
            )

        self.stubber.add_response(
            "describe_stack_events", valid_event_response(stack, "InitialEvents")
        )

        self.stubber.add_response(
            "describe_stack_events", valid_event_response(stack, "Event1")
        )

        with self.stubber:
            try:
                self.provider.tail_stack(
                    stack, threading.Event(), log_func=mock_log_func
                )
            except UnStubbedResponseError:
                # Eventually we run out of responses - could not happen in
                # regular execution
                # normally this would just be dealt with when the threads were
                # shutdown, but doing so here is a little difficult because
                # we can't control the `tail_stack` loop
                pass

        self.assertEqual(received_events[0]["EventId"], "Event1")

    def test_update_termination_protection(self) -> None:
        """Test update_termination_protection."""
        stack_name = "fake-stack"
        test_cases = [
            MutableMap(aws=False, defined=True, expected=True),
            MutableMap(aws=True, defined=False, expected=False),
            MutableMap(aws=True, defined=True, expected=None),
            MutableMap(aws=False, defined=False, expected=None),
        ]

        for test in test_cases:
            self.stubber.add_response(
                "describe_stacks",
                {
                    "Stacks": [
                        generate_describe_stacks_stack(
                            stack_name, termination_protection=test["aws"]
                        )
                    ]
                },
                {"StackName": stack_name},
            )
            if isinstance(test["expected"], bool):
                self.stubber.add_response(
                    "update_termination_protection",
                    {"StackId": stack_name},
                    {
                        "EnableTerminationProtection": test["expected"],
                        "StackName": stack_name,
                    },
                )
            with self.stubber:
                self.provider.update_termination_protection(stack_name, test["defined"])
            self.stubber.assert_no_pending_responses()