diff --git a/samcli/commands/bootstrap/exceptions.py b/samcli/commands/bootstrap/exceptions.py deleted file mode 100644 index e072f181cd..0000000000 --- a/samcli/commands/bootstrap/exceptions.py +++ /dev/null @@ -1,11 +0,0 @@ -""" -Exceptions that are raised by sam bootstrap -""" -from samcli.commands.exceptions import UserException - - -class ManagedStackError(UserException): - def __init__(self, ex): - self.ex = ex - message_fmt = f"Failed to create managed resources: {ex}" - super().__init__(message=message_fmt.format(ex=self.ex)) diff --git a/samcli/lib/bootstrap/bootstrap.py b/samcli/lib/bootstrap/bootstrap.py index fe4dc15beb..81c30c7748 100644 --- a/samcli/lib/bootstrap/bootstrap.py +++ b/samcli/lib/bootstrap/bootstrap.py @@ -4,129 +4,30 @@ import json import logging - -import boto3 - -import click - -from botocore.config import Config -from botocore.exceptions import ClientError, BotoCoreError, NoRegionError, NoCredentialsError - -from samcli.commands.bootstrap.exceptions import ManagedStackError from samcli import __version__ from samcli.cli.global_config import GlobalConfig -from samcli.commands.exceptions import UserException, CredentialsError, RegionError - +from samcli.commands.exceptions import UserException +from samcli.lib.utils.managed_cloudformation_stack import manage_stack as manage_cloudformation_stack SAM_CLI_STACK_NAME = "aws-sam-cli-managed-default" LOG = logging.getLogger(__name__) def manage_stack(profile, region): - try: - cloudformation_client = boto3.client("cloudformation", config=Config(region_name=region if region else None)) - except NoCredentialsError as ex: - raise CredentialsError( - "Error Setting Up Managed Stack Client: Unable to resolve credentials for the AWS SDK for Python client. " - "Please see their documentation for options to pass in credentials: " - "https://boto3.amazonaws.com/v1/documentation/api/latest/guide/configuration.html" - ) from ex - except NoRegionError as ex: - raise RegionError( - "Error Setting Up Managed Stack Client: Unable to resolve a region. " - "Please provide a region via the --region parameter or by the AWS_REGION environment variable." - ) from ex - return _create_or_get_stack(cloudformation_client) - - -def _create_or_get_stack(cloudformation_client): - try: - stack = None - try: - ds_resp = cloudformation_client.describe_stacks(StackName=SAM_CLI_STACK_NAME) - stacks = ds_resp["Stacks"] - stack = stacks[0] - click.echo("\n\tLooking for resources needed for deployment: Found!") - except ClientError: - click.echo("\n\tLooking for resources needed for deployment: Not found.") - stack = _create_stack(cloudformation_client) # exceptions are not captured from subcommands - - _check_sanity_of_stack(stack) - - outputs = stack["Outputs"] - try: - bucket_name = next(o for o in outputs if o["OutputKey"] == "SourceBucket")["OutputValue"] - except StopIteration as ex: - msg = ( - "Stack " + SAM_CLI_STACK_NAME + " exists, but is missing the managed source bucket key. " - "Failing as this stack was likely not created by the AWS SAM CLI." - ) - raise UserException(msg) from ex - # This bucket name is what we would write to a config file - return bucket_name - except (ClientError, BotoCoreError) as ex: - LOG.debug("Failed to create managed resources", exc_info=ex) - raise ManagedStackError(str(ex)) from ex - - -def _check_sanity_of_stack(stack): - tags = stack.get("Tags", None) - outputs = stack.get("Outputs", None) - - # For some edge cases, stack could be in invalid state - # Check if stack information contains the Tags and Outputs as we expected - if tags is None or outputs is None: - stack_state = stack.get("StackName", None) - msg = ( - f"Stack {SAM_CLI_STACK_NAME} is missing Tags and/or Outputs information and therefore not in a " - f"healthy state (Current state:{stack_state}). Failing as the stack was likely not created " - f"by the AWS SAM CLI" - ) - raise UserException(msg) + outputs = manage_cloudformation_stack( + profile=None, region=region, stack_name=SAM_CLI_STACK_NAME, template_body=_get_stack_template() + ) - # Sanity check for non-none stack? Sanity check for tag? try: - sam_cli_tag = next(t for t in tags if t["Key"] == "ManagedStackSource") - if not sam_cli_tag["Value"] == "AwsSamCli": - msg = ( - "Stack " - + SAM_CLI_STACK_NAME - + " ManagedStackSource tag shows " - + sam_cli_tag["Value"] - + " which does not match the AWS SAM CLI generated tag value of AwsSamCli. " - "Failing as the stack was likely not created by the AWS SAM CLI." - ) - raise UserException(msg) + bucket_name = next(o for o in outputs if o["OutputKey"] == "SourceBucket")["OutputValue"] except StopIteration as ex: msg = ( - "Stack " + SAM_CLI_STACK_NAME + " exists, but the ManagedStackSource tag is missing. " - "Failing as the stack was likely not created by the AWS SAM CLI." + "Stack " + SAM_CLI_STACK_NAME + " exists, but is missing the managed source bucket key. " + "Failing as this stack was likely not created by the AWS SAM CLI." ) raise UserException(msg) from ex - - -def _create_stack(cloudformation_client): - click.echo("\tCreating the required resources...") - change_set_name = "InitialCreation" - change_set_resp = cloudformation_client.create_change_set( - StackName=SAM_CLI_STACK_NAME, - TemplateBody=_get_stack_template(), - Tags=[{"Key": "ManagedStackSource", "Value": "AwsSamCli"}], - ChangeSetType="CREATE", - ChangeSetName=change_set_name, # this must be unique for the stack, but we only create so that's fine - ) - stack_id = change_set_resp["StackId"] - change_waiter = cloudformation_client.get_waiter("change_set_create_complete") - change_waiter.wait( - ChangeSetName=change_set_name, StackName=SAM_CLI_STACK_NAME, WaiterConfig={"Delay": 15, "MaxAttempts": 60} - ) - cloudformation_client.execute_change_set(ChangeSetName=change_set_name, StackName=SAM_CLI_STACK_NAME) - stack_waiter = cloudformation_client.get_waiter("stack_create_complete") - stack_waiter.wait(StackName=stack_id, WaiterConfig={"Delay": 15, "MaxAttempts": 60}) - ds_resp = cloudformation_client.describe_stacks(StackName=SAM_CLI_STACK_NAME) - stacks = ds_resp["Stacks"] - click.echo("\tSuccessfully created!") - return stacks[0] + # This bucket name is what we would write to a config file + return bucket_name def _get_stack_template(): diff --git a/samcli/lib/utils/managed_cloudformation_stack.py b/samcli/lib/utils/managed_cloudformation_stack.py new file mode 100644 index 0000000000..25973fbc8b --- /dev/null +++ b/samcli/lib/utils/managed_cloudformation_stack.py @@ -0,0 +1,136 @@ +""" +Bootstrap's user's development environment by creating cloud resources required by SAM CLI +""" + +import logging + +import boto3 + +import click + +from botocore.config import Config +from botocore.exceptions import ClientError, BotoCoreError, NoRegionError, NoCredentialsError, ProfileNotFound + +from samcli.commands.exceptions import UserException, CredentialsError, RegionError + + +SAM_CLI_STACK_PREFIX = "aws-sam-cli-managed-" +LOG = logging.getLogger(__name__) + + +class ManagedStackError(UserException): + def __init__(self, ex): + self.ex = ex + message_fmt = f"Failed to create managed resources: {ex}" + super().__init__(message=message_fmt.format(ex=self.ex)) + + +def manage_stack(profile, region, stack_name, template_body): + try: + if profile: + session = boto3.Session(profile_name=profile, region_name=region if region else None) + cloudformation_client = session.client("cloudformation") + else: + cloudformation_client = boto3.client( + "cloudformation", config=Config(region_name=region if region else None) + ) + except ProfileNotFound as ex: + raise CredentialsError( + f"Error Setting Up Managed Stack Client: the provided AWS name profile '{profile}' is not found. " + "please check the documentation for setting up a named profile: " + "https://docs.aws.amazon.com/cli/latest/userguide/cli-configure-profiles.html" + ) from ex + except NoCredentialsError as ex: + raise CredentialsError( + "Error Setting Up Managed Stack Client: Unable to resolve credentials for the AWS SDK for Python client. " + "Please see their documentation for options to pass in credentials: " + "https://boto3.amazonaws.com/v1/documentation/api/latest/guide/configuration.html" + ) from ex + except NoRegionError as ex: + raise RegionError( + "Error Setting Up Managed Stack Client: Unable to resolve a region. " + "Please provide a region via the --region parameter or by the AWS_REGION environment variable." + ) from ex + return _create_or_get_stack(cloudformation_client, stack_name, template_body) + + +def _create_or_get_stack(cloudformation_client, stack_name, template_body): + try: + ds_resp = cloudformation_client.describe_stacks(StackName=stack_name) + stacks = ds_resp["Stacks"] + stack = stacks[0] + click.echo("\n\tLooking for resources needed for deployment: Found!") + _check_sanity_of_stack(stack, stack_name) + return stack["Outputs"] + except ClientError: + click.echo("\n\tLooking for resources needed for deployment: Not found.") + + try: + stack = _create_stack( + cloudformation_client, stack_name, template_body + ) # exceptions are not captured from subcommands + _check_sanity_of_stack(stack, stack_name) + return stack["Outputs"] + except (ClientError, BotoCoreError) as ex: + LOG.debug("Failed to create managed resources", exc_info=ex) + raise ManagedStackError(str(ex)) from ex + + +def _check_sanity_of_stack(stack, stack_name): + tags = stack.get("Tags", None) + outputs = stack.get("Outputs", None) + + # For some edge cases, stack could be in invalid state + # Check if stack information contains the Tags and Outputs as we expected + if tags is None or outputs is None: + stack_state = stack.get("StackStatus", None) + msg = ( + f"Stack {stack_name} is missing Tags and/or Outputs information and therefore not in a " + f"healthy state (Current state:{stack_state}). Failing as the stack was likely not created " + f"by the AWS SAM CLI" + ) + raise UserException(msg) + + # Sanity check for non-none stack? Sanity check for tag? + try: + sam_cli_tag = next(t for t in tags if t["Key"] == "ManagedStackSource") + if not sam_cli_tag["Value"] == "AwsSamCli": + msg = ( + "Stack " + + stack_name + + " ManagedStackSource tag shows " + + sam_cli_tag["Value"] + + " which does not match the AWS SAM CLI generated tag value of AwsSamCli. " + "Failing as the stack was likely not created by the AWS SAM CLI." + ) + raise UserException(msg) + except StopIteration as ex: + msg = ( + "Stack " + stack_name + " exists, but the ManagedStackSource tag is missing. " + "Failing as the stack was likely not created by the AWS SAM CLI." + ) + raise UserException(msg) from ex + + +def _create_stack(cloudformation_client, stack_name, template_body): + click.echo("\tCreating the required resources...") + change_set_name = "InitialCreation" + change_set_resp = cloudformation_client.create_change_set( + StackName=stack_name, + TemplateBody=template_body, + Tags=[{"Key": "ManagedStackSource", "Value": "AwsSamCli"}], + ChangeSetType="CREATE", + ChangeSetName=change_set_name, # this must be unique for the stack, but we only create so that's fine + ) + stack_id = change_set_resp["StackId"] + change_waiter = cloudformation_client.get_waiter("change_set_create_complete") + change_waiter.wait( + ChangeSetName=change_set_name, StackName=stack_name, WaiterConfig={"Delay": 15, "MaxAttempts": 60} + ) + cloudformation_client.execute_change_set(ChangeSetName=change_set_name, StackName=stack_name) + stack_waiter = cloudformation_client.get_waiter("stack_create_complete") + stack_waiter.wait(StackName=stack_id, WaiterConfig={"Delay": 15, "MaxAttempts": 60}) + ds_resp = cloudformation_client.describe_stacks(StackName=stack_name) + stacks = ds_resp["Stacks"] + click.echo("\tSuccessfully created!") + return stacks[0] diff --git a/tests/unit/lib/bootstrap/test_bootstrap.py b/tests/unit/lib/bootstrap/test_bootstrap.py index 2b7d0d1f45..8094a404c0 100644 --- a/tests/unit/lib/bootstrap/test_bootstrap.py +++ b/tests/unit/lib/bootstrap/test_bootstrap.py @@ -1,234 +1,23 @@ from unittest import TestCase -from unittest.mock import patch, Mock +from unittest.mock import patch -import botocore.session - -from botocore.exceptions import ClientError, NoCredentialsError, NoRegionError -from botocore.stub import Stubber -from parameterized import parameterized - -from samcli.commands.bootstrap.exceptions import ManagedStackError -from samcli.commands.exceptions import UserException, CredentialsError, RegionError -from samcli.lib.bootstrap.bootstrap import manage_stack, _create_or_get_stack, _get_stack_template, SAM_CLI_STACK_NAME +from samcli.commands.exceptions import UserException +from samcli.lib.bootstrap.bootstrap import manage_stack class TestBootstrapManagedStack(TestCase): - def _stubbed_cf_client(self): - cf = botocore.session.get_session().create_client("cloudformation", region_name="us-west-2") - return [cf, Stubber(cf)] - - @patch("boto3.client") - def test_client_missing_credentials(self, boto_mock): - boto_mock.side_effect = NoCredentialsError() - with self.assertRaises(CredentialsError): - manage_stack("testprofile", "fake-region") - - @patch("boto3.client") - def test_client_missing_region(self, boto_mock): - boto_mock.side_effect = NoRegionError() - with self.assertRaises(RegionError): - manage_stack("testprofile", "fake-region") - - def test_new_stack(self): - stub_cf, stubber = self._stubbed_cf_client() - # first describe_stacks call will fail - ds_params = {"StackName": SAM_CLI_STACK_NAME} - stubber.add_client_error("describe_stacks", service_error_code="ClientError", expected_params=ds_params) - # creating change set - ccs_params = { - "StackName": SAM_CLI_STACK_NAME, - "TemplateBody": _get_stack_template(), - "Tags": [{"Key": "ManagedStackSource", "Value": "AwsSamCli"}], - "ChangeSetType": "CREATE", - "ChangeSetName": "InitialCreation", - } - ccs_resp = {"Id": "id", "StackId": "aws-sam-cli-managed-default"} - stubber.add_response("create_change_set", ccs_resp, ccs_params) - # describe change set creation status for waiter - dcs_params = {"ChangeSetName": "InitialCreation", "StackName": SAM_CLI_STACK_NAME} - dcs_resp = {"Status": "CREATE_COMPLETE"} - stubber.add_response("describe_change_set", dcs_resp, dcs_params) - # executing change set - ecs_params = {"ChangeSetName": "InitialCreation", "StackName": SAM_CLI_STACK_NAME} - ecs_resp = {} - stubber.add_response("execute_change_set", ecs_resp, ecs_params) - # two describe_stacks calls will succeed - one for waiter, one direct - post_create_ds_resp = { - "Stacks": [ - { - "StackName": SAM_CLI_STACK_NAME, - "CreationTime": "2019-11-13", - "StackStatus": "CREATE_COMPLETE", - "Tags": [{"Key": "ManagedStackSource", "Value": "AwsSamCli"}], - "Outputs": [{"OutputKey": "SourceBucket", "OutputValue": "generated-src-bucket"}], - } - ] - } - stubber.add_response("describe_stacks", post_create_ds_resp, ds_params) - stubber.add_response("describe_stacks", post_create_ds_resp, ds_params) - stubber.activate() - _create_or_get_stack(stub_cf) - stubber.assert_no_pending_responses() - stubber.deactivate() - - def test_stack_exists(self): - stub_cf, stubber = self._stubbed_cf_client() - ds_resp = { - "Stacks": [ - { - "StackName": SAM_CLI_STACK_NAME, - "CreationTime": "2019-11-13", - "StackStatus": "CREATE_COMPLETE", - "Tags": [{"Key": "ManagedStackSource", "Value": "AwsSamCli"}], - "Outputs": [{"OutputKey": "SourceBucket", "OutputValue": "generated-src-bucket"}], - } - ] - } - ds_params = {"StackName": SAM_CLI_STACK_NAME} - stubber.add_response("describe_stacks", ds_resp, ds_params) - stubber.activate() - _create_or_get_stack(stub_cf) - stubber.assert_no_pending_responses() - stubber.deactivate() - - def test_stack_missing_bucket(self): - stub_cf, stubber = self._stubbed_cf_client() - ds_resp = { - "Stacks": [ - { - "StackName": SAM_CLI_STACK_NAME, - "CreationTime": "2019-11-13", - "StackStatus": "CREATE_COMPLETE", - "Tags": [{"Key": "ManagedStackSource", "Value": "AwsSamCli"}], - "Outputs": [], - } - ] - } - ds_params = {"StackName": SAM_CLI_STACK_NAME} - stubber.add_response("describe_stacks", ds_resp, ds_params) - stubber.activate() - with self.assertRaises(UserException): - _create_or_get_stack(stub_cf) - stubber.assert_no_pending_responses() - stubber.deactivate() - - def test_stack_missing_tag(self): - stub_cf, stubber = self._stubbed_cf_client() - ds_resp = { - "Stacks": [ - { - "StackName": SAM_CLI_STACK_NAME, - "CreationTime": "2019-11-13", - "StackStatus": "CREATE_COMPLETE", - "Tags": [], - "Outputs": [{"OutputKey": "SourceBucket", "OutputValue": "generated-src-bucket"}], - } - ] - } - ds_params = {"StackName": SAM_CLI_STACK_NAME} - stubber.add_response("describe_stacks", ds_resp, ds_params) - stubber.activate() + @patch("samcli.lib.bootstrap.bootstrap.manage_cloudformation_stack") + def test_stack_missing_bucket(self, manage_cfn_stack_mock): + manage_cfn_stack_mock.return_value = [] with self.assertRaises(UserException): - _create_or_get_stack(stub_cf) - stubber.assert_no_pending_responses() - stubber.deactivate() - - def test_stack_wrong_tag(self): - stub_cf, stubber = self._stubbed_cf_client() - ds_resp = { - "Stacks": [ - { - "StackName": SAM_CLI_STACK_NAME, - "CreationTime": "2019-11-13", - "StackStatus": "CREATE_COMPLETE", - "Tags": [{"Key": "ManagedStackSource", "Value": "WHY WOULD YOU EVEN DO THIS"}], - "Outputs": [{"OutputKey": "SourceBucket", "OutputValue": "generated-src-bucket"}], - } - ] - } - ds_params = {"StackName": SAM_CLI_STACK_NAME} - stubber.add_response("describe_stacks", ds_resp, ds_params) - stubber.activate() - with self.assertRaises(UserException): - _create_or_get_stack(stub_cf) - stubber.assert_no_pending_responses() - stubber.deactivate() - - def test_change_set_creation_fails(self): - stub_cf, stubber = self._stubbed_cf_client() - # first describe_stacks call will fail - ds_params = {"StackName": SAM_CLI_STACK_NAME} - stubber.add_client_error("describe_stacks", service_error_code="ClientError", expected_params=ds_params) - # creating change set - fails - ccs_params = { - "StackName": SAM_CLI_STACK_NAME, - "TemplateBody": _get_stack_template(), - "Tags": [{"Key": "ManagedStackSource", "Value": "AwsSamCli"}], - "ChangeSetType": "CREATE", - "ChangeSetName": "InitialCreation", - } - stubber.add_client_error("create_change_set", service_error_code="ClientError", expected_params=ccs_params) - stubber.activate() - with self.assertRaises(ManagedStackError): - _create_or_get_stack(stub_cf) - stubber.assert_no_pending_responses() - stubber.deactivate() - - def test_change_set_execution_fails(self): - stub_cf, stubber = self._stubbed_cf_client() - # first describe_stacks call will fail - ds_params = {"StackName": SAM_CLI_STACK_NAME} - stubber.add_client_error("describe_stacks", service_error_code="ClientError", expected_params=ds_params) - # creating change set - ccs_params = { - "StackName": SAM_CLI_STACK_NAME, - "TemplateBody": _get_stack_template(), - "Tags": [{"Key": "ManagedStackSource", "Value": "AwsSamCli"}], - "ChangeSetType": "CREATE", - "ChangeSetName": "InitialCreation", - } - ccs_resp = {"Id": "id", "StackId": "aws-sam-cli-managed-default"} - stubber.add_response("create_change_set", ccs_resp, ccs_params) - # describe change set creation status for waiter - dcs_params = {"ChangeSetName": "InitialCreation", "StackName": SAM_CLI_STACK_NAME} - dcs_resp = {"Status": "CREATE_COMPLETE"} - stubber.add_response("describe_change_set", dcs_resp, dcs_params) - # executing change set - fails - ecs_params = {"ChangeSetName": "InitialCreation", "StackName": SAM_CLI_STACK_NAME} - stubber.add_client_error( - "execute_change_set", service_error_code="InsufficientCapabilities", expected_params=ecs_params - ) - stubber.activate() - with self.assertRaises(ManagedStackError): - _create_or_get_stack(stub_cf) - stubber.assert_no_pending_responses() - stubber.deactivate() - - @parameterized.expand( - [ - ([{"Key": "ManagedStackSource", "Value": "WHY WOULD YOU EVEN DO THIS"}], None), - (None, [{"OutputKey": "SourceBucket", "OutputValue": "generated-src-bucket"}]), - (None, None), - ] - ) - def test_stack_is_invalid_state(self, tags, outputs): - stub_cf, stubber = self._stubbed_cf_client() - ds_resp = { - "Stacks": [{"StackName": SAM_CLI_STACK_NAME, "CreationTime": "2019-11-13", "StackStatus": "CREATE_FAILED"}] - } - - # add Tags or Outputs information if it exists - # Boto client is missing this information if stack is in invalid state - if tags: - ds_resp["Stacks"][0]["Tags"] = tags - - if outputs: - ds_resp["Stacks"][0]["Outputs"] = outputs - - ds_params = {"StackName": SAM_CLI_STACK_NAME} - stubber.add_response("describe_stacks", ds_resp, ds_params) - stubber.activate() + manage_stack("testProfile", "fakeRegion") + manage_cfn_stack_mock.return_value = [{"OutputKey": "NotSourceBucket", "OutputValue": "AnyValue"}] with self.assertRaises(UserException): - _create_or_get_stack(stub_cf) - stubber.assert_no_pending_responses() - stubber.deactivate() + manage_stack("testProfile", "fakeRegion") + + @patch("samcli.lib.bootstrap.bootstrap.manage_cloudformation_stack") + def test_manage_stack_happy_case(self, manage_cfn_stack_mock): + expected_bucket_name = "BucketName" + manage_cfn_stack_mock.return_value = [{"OutputKey": "SourceBucket", "OutputValue": expected_bucket_name}] + actual_bucket_name = manage_stack("testProfile", "fakeRegion") + self.assertEqual(actual_bucket_name, expected_bucket_name) diff --git a/tests/unit/lib/utils/test_managed_cloudformation_stack.py b/tests/unit/lib/utils/test_managed_cloudformation_stack.py new file mode 100644 index 0000000000..9f1ea0915a --- /dev/null +++ b/tests/unit/lib/utils/test_managed_cloudformation_stack.py @@ -0,0 +1,219 @@ +from unittest import TestCase +from unittest.mock import patch, Mock + +import botocore.session + +from botocore.exceptions import ClientError, NoCredentialsError, NoRegionError, ProfileNotFound +from botocore.stub import Stubber +from parameterized import parameterized + +from samcli.commands.exceptions import UserException, CredentialsError, RegionError +from samcli.lib.bootstrap.bootstrap import _get_stack_template, SAM_CLI_STACK_NAME +from samcli.lib.utils.managed_cloudformation_stack import manage_stack, _create_or_get_stack, ManagedStackError + + +class TestManagedCloudFormationStack(TestCase): + def _stubbed_cf_client(self): + cf = botocore.session.get_session().create_client("cloudformation", region_name="us-west-2") + return [cf, Stubber(cf)] + + @patch("boto3.Session") + def test_session_missing_profile(self, boto_mock): + boto_mock.side_effect = ProfileNotFound(profile="test-profile") + with self.assertRaises(CredentialsError): + manage_stack("test-profile", "fake-region", SAM_CLI_STACK_NAME, _get_stack_template()) + + @patch("boto3.client") + def test_client_missing_credentials(self, boto_mock): + boto_mock.side_effect = NoCredentialsError() + with self.assertRaises(CredentialsError): + manage_stack(None, "fake-region", SAM_CLI_STACK_NAME, _get_stack_template()) + + @patch("boto3.client") + def test_client_missing_region(self, boto_mock): + boto_mock.side_effect = NoRegionError() + with self.assertRaises(RegionError): + manage_stack(None, "fake-region", SAM_CLI_STACK_NAME, _get_stack_template()) + + def test_new_stack(self): + stub_cf, stubber = self._stubbed_cf_client() + # first describe_stacks call will fail + ds_params = {"StackName": SAM_CLI_STACK_NAME} + stubber.add_client_error("describe_stacks", service_error_code="ClientError", expected_params=ds_params) + # creating change set + ccs_params = { + "StackName": SAM_CLI_STACK_NAME, + "TemplateBody": _get_stack_template(), + "Tags": [{"Key": "ManagedStackSource", "Value": "AwsSamCli"}], + "ChangeSetType": "CREATE", + "ChangeSetName": "InitialCreation", + } + ccs_resp = {"Id": "id", "StackId": "aws-sam-cli-managed-default"} + stubber.add_response("create_change_set", ccs_resp, ccs_params) + # describe change set creation status for waiter + dcs_params = {"ChangeSetName": "InitialCreation", "StackName": SAM_CLI_STACK_NAME} + dcs_resp = {"Status": "CREATE_COMPLETE"} + stubber.add_response("describe_change_set", dcs_resp, dcs_params) + # executing change set + ecs_params = {"ChangeSetName": "InitialCreation", "StackName": SAM_CLI_STACK_NAME} + ecs_resp = {} + stubber.add_response("execute_change_set", ecs_resp, ecs_params) + # two describe_stacks calls will succeed - one for waiter, one direct + post_create_ds_resp = { + "Stacks": [ + { + "StackName": SAM_CLI_STACK_NAME, + "CreationTime": "2019-11-13", + "StackStatus": "CREATE_COMPLETE", + "Tags": [{"Key": "ManagedStackSource", "Value": "AwsSamCli"}], + "Outputs": [{"OutputKey": "SourceBucket", "OutputValue": "generated-src-bucket"}], + } + ] + } + stubber.add_response("describe_stacks", post_create_ds_resp, ds_params) + stubber.add_response("describe_stacks", post_create_ds_resp, ds_params) + stubber.activate() + _create_or_get_stack(stub_cf, SAM_CLI_STACK_NAME, _get_stack_template()) + stubber.assert_no_pending_responses() + stubber.deactivate() + + def test_stack_exists(self): + stub_cf, stubber = self._stubbed_cf_client() + ds_resp = { + "Stacks": [ + { + "StackName": SAM_CLI_STACK_NAME, + "CreationTime": "2019-11-13", + "StackStatus": "CREATE_COMPLETE", + "Tags": [{"Key": "ManagedStackSource", "Value": "AwsSamCli"}], + "Outputs": [{"OutputKey": "SourceBucket", "OutputValue": "generated-src-bucket"}], + } + ] + } + ds_params = {"StackName": SAM_CLI_STACK_NAME} + stubber.add_response("describe_stacks", ds_resp, ds_params) + stubber.activate() + _create_or_get_stack(stub_cf, SAM_CLI_STACK_NAME, _get_stack_template()) + stubber.assert_no_pending_responses() + stubber.deactivate() + + def test_stack_missing_tag(self): + stub_cf, stubber = self._stubbed_cf_client() + ds_resp = { + "Stacks": [ + { + "StackName": SAM_CLI_STACK_NAME, + "CreationTime": "2019-11-13", + "StackStatus": "CREATE_COMPLETE", + "Tags": [], + "Outputs": [{"OutputKey": "SourceBucket", "OutputValue": "generated-src-bucket"}], + } + ] + } + ds_params = {"StackName": SAM_CLI_STACK_NAME} + stubber.add_response("describe_stacks", ds_resp, ds_params) + stubber.activate() + with self.assertRaises(UserException): + _create_or_get_stack(stub_cf, SAM_CLI_STACK_NAME, _get_stack_template()) + stubber.assert_no_pending_responses() + stubber.deactivate() + + def test_stack_wrong_tag(self): + stub_cf, stubber = self._stubbed_cf_client() + ds_resp = { + "Stacks": [ + { + "StackName": SAM_CLI_STACK_NAME, + "CreationTime": "2019-11-13", + "StackStatus": "CREATE_COMPLETE", + "Tags": [{"Key": "ManagedStackSource", "Value": "WHY WOULD YOU EVEN DO THIS"}], + "Outputs": [{"OutputKey": "SourceBucket", "OutputValue": "generated-src-bucket"}], + } + ] + } + ds_params = {"StackName": SAM_CLI_STACK_NAME} + stubber.add_response("describe_stacks", ds_resp, ds_params) + stubber.activate() + with self.assertRaises(UserException): + _create_or_get_stack(stub_cf, SAM_CLI_STACK_NAME, _get_stack_template()) + stubber.assert_no_pending_responses() + stubber.deactivate() + + def test_change_set_creation_fails(self): + stub_cf, stubber = self._stubbed_cf_client() + # first describe_stacks call will fail + ds_params = {"StackName": SAM_CLI_STACK_NAME} + stubber.add_client_error("describe_stacks", service_error_code="ClientError", expected_params=ds_params) + # creating change set - fails + ccs_params = { + "StackName": SAM_CLI_STACK_NAME, + "TemplateBody": _get_stack_template(), + "Tags": [{"Key": "ManagedStackSource", "Value": "AwsSamCli"}], + "ChangeSetType": "CREATE", + "ChangeSetName": "InitialCreation", + } + stubber.add_client_error("create_change_set", service_error_code="ClientError", expected_params=ccs_params) + stubber.activate() + with self.assertRaises(ManagedStackError): + _create_or_get_stack(stub_cf, SAM_CLI_STACK_NAME, _get_stack_template()) + stubber.assert_no_pending_responses() + stubber.deactivate() + + def test_change_set_execution_fails(self): + stub_cf, stubber = self._stubbed_cf_client() + # first describe_stacks call will fail + ds_params = {"StackName": SAM_CLI_STACK_NAME} + stubber.add_client_error("describe_stacks", service_error_code="ClientError", expected_params=ds_params) + # creating change set + ccs_params = { + "StackName": SAM_CLI_STACK_NAME, + "TemplateBody": _get_stack_template(), + "Tags": [{"Key": "ManagedStackSource", "Value": "AwsSamCli"}], + "ChangeSetType": "CREATE", + "ChangeSetName": "InitialCreation", + } + ccs_resp = {"Id": "id", "StackId": "aws-sam-cli-managed-default"} + stubber.add_response("create_change_set", ccs_resp, ccs_params) + # describe change set creation status for waiter + dcs_params = {"ChangeSetName": "InitialCreation", "StackName": SAM_CLI_STACK_NAME} + dcs_resp = {"Status": "CREATE_COMPLETE"} + stubber.add_response("describe_change_set", dcs_resp, dcs_params) + # executing change set - fails + ecs_params = {"ChangeSetName": "InitialCreation", "StackName": SAM_CLI_STACK_NAME} + stubber.add_client_error( + "execute_change_set", service_error_code="InsufficientCapabilities", expected_params=ecs_params + ) + stubber.activate() + with self.assertRaises(ManagedStackError): + _create_or_get_stack(stub_cf, SAM_CLI_STACK_NAME, _get_stack_template()) + stubber.assert_no_pending_responses() + stubber.deactivate() + + @parameterized.expand( + [ + ([{"Key": "ManagedStackSource", "Value": "WHY WOULD YOU EVEN DO THIS"}], None), + (None, [{"OutputKey": "SourceBucket", "OutputValue": "generated-src-bucket"}]), + (None, None), + ] + ) + def test_stack_is_invalid_state(self, tags, outputs): + stub_cf, stubber = self._stubbed_cf_client() + ds_resp = { + "Stacks": [{"StackName": SAM_CLI_STACK_NAME, "CreationTime": "2019-11-13", "StackStatus": "CREATE_FAILED"}] + } + + # add Tags or Outputs information if it exists + # Boto client is missing this information if stack is in invalid state + if tags: + ds_resp["Stacks"][0]["Tags"] = tags + + if outputs: + ds_resp["Stacks"][0]["Outputs"] = outputs + + ds_params = {"StackName": SAM_CLI_STACK_NAME} + stubber.add_response("describe_stacks", ds_resp, ds_params) + stubber.activate() + with self.assertRaises(UserException): + _create_or_get_stack(stub_cf, SAM_CLI_STACK_NAME, _get_stack_template()) + stubber.assert_no_pending_responses() + stubber.deactivate()