diff --git a/samcli/commands/_utils/options.py b/samcli/commands/_utils/options.py index 20b46cf1c8..72973ad43e 100644 --- a/samcli/commands/_utils/options.py +++ b/samcli/commands/_utils/options.py @@ -13,6 +13,7 @@ _TEMPLATE_OPTION_DEFAULT_VALUE = "template.[yaml|yml]" +DEFAULT_STACK_NAME = "sam-app" LOG = logging.getLogger(__name__) @@ -56,6 +57,28 @@ def get_or_default_template_file_name(ctx, param, provided_value, include_build) return result +def guided_deploy_stack_name(ctx, param, provided_value): + """ + Provide a default value for stack name if invoked with a guided deploy. + :param ctx: Click Context + :param param: Param name + :param provided_value: Value provided by Click, it would be the value provided by the user. + :return: Actual value to be used in the CLI + """ + + guided = ctx.params.get("guided", False) or ctx.params.get("g", False) + + if not guided and not provided_value: + raise click.BadOptionUsage( + option_name=param.name, + ctx=ctx, + message="Missing option '--stack-name', 'sam deploy –guided' can " + "be used to provide and save needed parameters for future deploys.", + ) + + return provided_value if provided_value else DEFAULT_STACK_NAME + + def template_common_option(f): """ Common ClI option for template @@ -127,9 +150,9 @@ def parameter_override_click_option(): cls=OptionNargs, type=CfnParameterOverridesType(), default={}, - help="Optional. A string that contains CloudFormation parameter overrides encoded as key=value " - "pairs. Use the same format as the AWS CLI, e.g. 'ParameterKey=KeyPairName," - "ParameterValue=MyKey ParameterKey=InstanceType,ParameterValue=t1.micro'", + help="Optional. A string that contains AWS CloudFormation parameter overrides encoded as key=value pairs." + "For example, 'ParameterKey=KeyPairName,ParameterValue=MyKey ParameterKey=InstanceType," + "ParameterValue=t1.micro' or KeyPairName=MyKey InstanceType=t1.micro", ) diff --git a/samcli/commands/deploy/command.py b/samcli/commands/deploy/command.py index d2f3a8fcc6..a0264db328 100644 --- a/samcli/commands/deploy/command.py +++ b/samcli/commands/deploy/command.py @@ -18,6 +18,7 @@ template_click_option, metadata_override_option, _space_separated_list_func_type, + guided_deploy_stack_name, ) from samcli.commands._utils.template import get_template_parameters from samcli.commands.deploy.exceptions import GuidedDeployFailedError @@ -47,10 +48,19 @@ help=HELP_TEXT, ) @configuration_option(provider=TomlProvider(section=CONFIG_SECTION)) +@click.option( + "--guided", + "-g", + required=False, + is_flag=True, + is_eager=True, + help="Specify this flag to allow SAM CLI to guide you through the deployment using guided prompts.", +) @template_click_option(include_build=True) @click.option( "--stack-name", - required=True, + required=False, + callback=guided_deploy_stack_name, help="The name of the AWS CloudFormation stack you're deploying to. " "If you specify an existing stack, the command updates the stack. " "If you specify a new stack, the command creates it.", @@ -121,14 +131,6 @@ help="Indicates whether to use JSON as the format for " "the output AWS CloudFormation template. YAML is used by default.", ) -@click.option( - "--guided", - "-g", - required=False, - is_flag=True, - is_eager=True, - help="Specify this flag to allow SAM CLI to guide you through the deployment using guided prompts.", -) @metadata_override_option @notification_arns_override_option @tags_override_option @@ -213,6 +215,7 @@ def do_cli( _parameter_overrides = None guided_stack_name = None guided_s3_bucket = None + guided_s3_prefix = None guided_region = None if guided: @@ -221,7 +224,7 @@ def do_cli( _parameter_override_keys = get_template_parameters(template_file=template_file) - guided_stack_name, guided_s3_bucket, guided_region, guided_profile, changeset_decision, _capabilities, _parameter_overrides, save_to_config = guided_deploy( + guided_stack_name, guided_s3_bucket, guided_s3_prefix, guided_region, guided_profile, changeset_decision, _capabilities, _parameter_overrides, save_to_config = guided_deploy( stack_name, s3_bucket, region, profile, confirm_changeset, _parameter_override_keys, parameter_overrides ) @@ -230,6 +233,7 @@ def do_cli( template_file, stack_name=guided_stack_name, s3_bucket=guided_s3_bucket, + s3_prefix=guided_s3_prefix, region=guided_region, profile=guided_profile, confirm_changeset=changeset_decision, @@ -251,7 +255,7 @@ def do_cli( with PackageContext( template_file=template_file, s3_bucket=guided_s3_bucket if guided else s3_bucket, - s3_prefix=s3_prefix, + s3_prefix=guided_s3_prefix if guided else s3_prefix, output_template_file=output_template_file.name, kms_key_id=kms_key_id, use_json=use_json, @@ -268,7 +272,7 @@ def do_cli( stack_name=guided_stack_name if guided else stack_name, s3_bucket=guided_s3_bucket if guided else s3_bucket, force_upload=force_upload, - s3_prefix=s3_prefix, + s3_prefix=guided_s3_prefix if guided else s3_prefix, kms_key_id=kms_key_id, parameter_overrides=sanitize_parameter_overrides(_parameter_overrides) if guided else parameter_overrides, capabilities=_capabilities if guided else capabilities, @@ -301,6 +305,7 @@ def guided_deploy( ) stack_name = click.prompt(f"\t{start_bold}Stack Name{end_bold}", default=default_stack_name, type=click.STRING) + s3_prefix = stack_name region = click.prompt(f"\t{start_bold}AWS Region{end_bold}", default=default_region, type=click.STRING) input_parameter_overrides = prompt_parameters(parameter_override_keys, start_bold, end_bold) @@ -327,6 +332,7 @@ def guided_deploy( return ( stack_name, s3_bucket, + s3_prefix, region, profile, confirm_changeset, @@ -347,11 +353,10 @@ def prompt_parameters(parameter_override_keys, start_bold, end_bold): ) _prompted_param_overrides[parameter_key] = {"Value": parameter, "Hidden": True} else: + # Make sure the default is casted to a string. parameter = click.prompt( f"\t{start_bold}Parameter {parameter_key}{end_bold}", - default=_prompted_param_overrides.get( - parameter_key, parameter_properties.get("Default", "No default specified") - ), + default=_prompted_param_overrides.get(parameter_key, str(parameter_properties.get("Default", ""))), type=click.STRING, ) _prompted_param_overrides[parameter_key] = {"Value": parameter, "Hidden": False} diff --git a/samcli/lib/bootstrap/bootstrap.py b/samcli/lib/bootstrap/bootstrap.py index 55e63891b8..900e8317b6 100644 --- a/samcli/lib/bootstrap/bootstrap.py +++ b/samcli/lib/bootstrap/bootstrap.py @@ -15,7 +15,7 @@ from samcli.commands.exceptions import UserException, CredentialsError, RegionError -SAM_CLI_STACK_NAME = "aws-sam-cli-managed-stack" +SAM_CLI_STACK_NAME = "aws-sam-cli-managed-default" def manage_stack(profile, region): @@ -120,6 +120,27 @@ def _get_stack_template(): - Key: ManagedStackSource Value: AwsSamCli + SamCliSourceBucketBucketPolicy: + Type: AWS::S3::BucketPolicy + Properties: + Bucket: !Ref SamCliSourceBucket + PolicyDocument: + Statement: + - + Action: + - "s3:GetObject" + Effect: "Allow" + Resource: + Fn::Join: + - "" + - + - "arn:aws:s3:::" + - + !Ref SamCliSourceBucket + - "/*" + Principal: + Service: serverlessrepo.amazonaws.com + Outputs: SourceBucket: Value: !Ref SamCliSourceBucket diff --git a/samcli/lib/deploy/deployer.py b/samcli/lib/deploy/deployer.py index c05a38697c..170bcc8778 100644 --- a/samcli/lib/deploy/deployer.py +++ b/samcli/lib/deploy/deployer.py @@ -129,7 +129,6 @@ def create_changeset( :param tags: Array of tags passed to CloudFormation :return: """ - if not self.has_stack(stack_name): changeset_type = "CREATE" # When creating a new stack, UsePreviousValue=True is invalid. @@ -178,12 +177,16 @@ def create_changeset( kwargs["RoleARN"] = role_arn if notification_arns is not None: kwargs["NotificationARNs"] = notification_arns + return self._create_change_set(stack_name=stack_name, changeset_type=changeset_type, **kwargs) + + def _create_change_set(self, stack_name, changeset_type, **kwargs): try: resp = self._client.create_change_set(**kwargs) return resp, changeset_type except botocore.exceptions.ClientError as ex: if "The bucket you are attempting to access must be addressed using the specified endpoint" in str(ex): raise DeployBucketInDifferentRegionError(f"Failed to create/update stack {stack_name}") + raise ChangeSetError(stack_name=stack_name, msg=str(ex)) except Exception as ex: LOG.debug("Unable to create changeset", exc_info=ex) diff --git a/samcli/lib/package/s3_uploader.py b/samcli/lib/package/s3_uploader.py index efd7397555..3e9e167ced 100644 --- a/samcli/lib/package/s3_uploader.py +++ b/samcli/lib/package/s3_uploader.py @@ -205,3 +205,5 @@ def on_progress(self, bytes_transferred, **kwargs): "\rUploading to %s %s / %s (%.2f%%)" % (self._remote_path, self._seen_so_far, self._size, percentage) ) sys.stderr.flush() + if int(percentage) == 100: + sys.stderr.write("\n") diff --git a/tests/integration/deploy/test_deploy_command.py b/tests/integration/deploy/test_deploy_command.py index f36872aed7..19d3bdd342 100644 --- a/tests/integration/deploy/test_deploy_command.py +++ b/tests/integration/deploy/test_deploy_command.py @@ -193,7 +193,7 @@ def test_deploy_without_stack_name(self, template_file): deploy_process_execute = Popen(deploy_command_list, stdout=PIPE, stderr=PIPE) deploy_process_execute.wait() # Error no stack name present - self.assertEqual(deploy_process_execute.returncode, 1) + self.assertEqual(deploy_process_execute.returncode, 2) @parameterized.expand(["aws-serverless-function.yaml"]) def test_deploy_without_capabilities(self, template_file): diff --git a/tests/unit/cli/test_cli_config_file.py b/tests/unit/cli/test_cli_config_file.py index 0aed94ee74..490e4029b0 100644 --- a/tests/unit/cli/test_cli_config_file.py +++ b/tests/unit/cli/test_cli_config_file.py @@ -10,9 +10,11 @@ class MockContext: - def __init__(self, info_name, parent): + def __init__(self, info_name, parent, params=None, command=None): self.info_name = info_name self.parent = parent + self.params = params + self.command = command class TestTomlProvider(TestCase): diff --git a/tests/unit/commands/_utils/test_options.py b/tests/unit/commands/_utils/test_options.py index b2e82d6618..e7ebb65c41 100644 --- a/tests/unit/commands/_utils/test_options.py +++ b/tests/unit/commands/_utils/test_options.py @@ -5,8 +5,16 @@ import os from unittest import TestCase -from unittest.mock import patch -from samcli.commands._utils.options import get_or_default_template_file_name, _TEMPLATE_OPTION_DEFAULT_VALUE +from unittest.mock import patch, MagicMock + +import click + +from samcli.commands._utils.options import ( + get_or_default_template_file_name, + _TEMPLATE_OPTION_DEFAULT_VALUE, + guided_deploy_stack_name, +) +from tests.unit.cli.test_cli_config_file import MockContext class Mock: @@ -71,3 +79,45 @@ def test_verify_ctx(self, os_mock): self.assertEqual(result, "a/b/c/absPath") self.assertEqual(ctx.samconfig_dir, "a/b/c") os_mock.path.abspath.assert_called_with(expected) + + +class TestGuidedDeployStackName(TestCase): + def test_must_return_provided_value_guided(self): + stack_name = "provided-stack" + mock_params = MagicMock() + mock_params.get = MagicMock(return_value=True) + result = guided_deploy_stack_name( + ctx=MockContext(info_name="test", parent=None, params=mock_params), + param=MagicMock(), + provided_value=stack_name, + ) + self.assertEqual(result, stack_name) + + def test_must_return_default_value_guided(self): + stack_name = None + mock_params = MagicMock() + mock_params.get = MagicMock(return_value=True) + result = guided_deploy_stack_name( + ctx=MockContext(info_name="test", parent=None, params=mock_params), + param=MagicMock(), + provided_value=stack_name, + ) + self.assertEqual(result, "sam-app") + + def test_must_return_provided_value_non_guided(self): + stack_name = "provided-stack" + mock_params = MagicMock() + mock_params.get = MagicMock(return_value=False) + result = guided_deploy_stack_name(ctx=MagicMock(), param=MagicMock(), provided_value=stack_name) + self.assertEqual(result, "provided-stack") + + def test_exception_missing_parameter_no_value_non_guided(self): + stack_name = None + mock_params = MagicMock() + mock_params.get = MagicMock(return_value=False) + with self.assertRaises(click.BadOptionUsage): + guided_deploy_stack_name( + ctx=MockContext(info_name="test", parent=None, params=mock_params), + param=MagicMock(), + provided_value=stack_name, + ) diff --git a/tests/unit/commands/deploy/test_command.py b/tests/unit/commands/deploy/test_command.py index 3a17a4c7cd..5f7331494d 100644 --- a/tests/unit/commands/deploy/test_command.py +++ b/tests/unit/commands/deploy/test_command.py @@ -146,7 +146,7 @@ def test_all_args_guided( stack_name="sam-app", s3_bucket="managed-s3-bucket", force_upload=self.force_upload, - s3_prefix=self.s3_prefix, + s3_prefix="sam-app", kms_key_id=self.kms_key_id, parameter_overrides={"Myparameter": "guidedParameter", "MyNoEchoParameter": "secure"}, capabilities=self.capabilities, @@ -169,6 +169,7 @@ def test_all_args_guided( region="us-east-1", s3_bucket="managed-s3-bucket", stack_name="sam-app", + s3_prefix="sam-app", parameter_overrides={ "Myparameter": {"Value": "guidedParameter", "Hidden": False}, "MyNoEchoParameter": {"Value": "secure", "Hidden": True}, @@ -238,7 +239,7 @@ def test_all_args_guided_no_save_echo_param_to_config( stack_name="sam-app", s3_bucket="managed-s3-bucket", force_upload=self.force_upload, - s3_prefix=self.s3_prefix, + s3_prefix="sam-app", kms_key_id=self.kms_key_id, parameter_overrides={"Myparameter": "guidedParameter", "MyNoEchoParameter": "secure"}, capabilities=self.capabilities, @@ -256,12 +257,13 @@ def test_all_args_guided_no_save_echo_param_to_config( mock_managed_stack.assert_called_with(profile=self.profile, region="us-east-1") self.assertEqual(context_mock.run.call_count, 1) - self.assertEqual(mock_sam_config.put.call_count, 6) + self.assertEqual(mock_sam_config.put.call_count, 7) self.assertEqual( mock_sam_config.put.call_args_list, [ call(["deploy"], "parameters", "stack_name", "sam-app"), call(["deploy"], "parameters", "s3_bucket", "managed-s3-bucket"), + call(["deploy"], "parameters", "s3_prefix", "sam-app"), call(["deploy"], "parameters", "region", "us-east-1"), call(["deploy"], "parameters", "confirm_changeset", True), call(["deploy"], "parameters", "capabilities", "CAPABILITY_IAM"), @@ -325,7 +327,7 @@ def test_all_args_guided_no_params_save_config( stack_name="sam-app", s3_bucket="managed-s3-bucket", force_upload=self.force_upload, - s3_prefix=self.s3_prefix, + s3_prefix="sam-app", kms_key_id=self.kms_key_id, parameter_overrides=self.parameter_overrides, capabilities=self.capabilities, @@ -401,7 +403,7 @@ def test_all_args_guided_no_params_no_save_config( stack_name="sam-app", s3_bucket="managed-s3-bucket", force_upload=self.force_upload, - s3_prefix=self.s3_prefix, + s3_prefix="sam-app", kms_key_id=self.kms_key_id, parameter_overrides=self.parameter_overrides, capabilities=self.capabilities, diff --git a/tests/unit/lib/bootstrap/test_bootstrap.py b/tests/unit/lib/bootstrap/test_bootstrap.py index ccc073abdf..f1653af61a 100644 --- a/tests/unit/lib/bootstrap/test_bootstrap.py +++ b/tests/unit/lib/bootstrap/test_bootstrap.py @@ -44,7 +44,7 @@ def test_new_stack(self): "ChangeSetType": "CREATE", "ChangeSetName": "InitialCreation", } - ccs_resp = {"Id": "id", "StackId": "aws-sam-cli-managed-stack"} + 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} @@ -189,7 +189,7 @@ def test_change_set_execution_fails(self): "ChangeSetType": "CREATE", "ChangeSetName": "InitialCreation", } - ccs_resp = {"Id": "id", "StackId": "aws-sam-cli-managed-stack"} + 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} diff --git a/tests/unit/lib/deploy/test_deployer.py b/tests/unit/lib/deploy/test_deployer.py index 8749f1bb01..1eb1567cec 100644 --- a/tests/unit/lib/deploy/test_deployer.py +++ b/tests/unit/lib/deploy/test_deployer.py @@ -193,6 +193,26 @@ def test_create_changeset_ClientErrorException(self): tags={"unit": "true"}, ) + def test_create_changeset_ClientErrorException_generic(self): + self.deployer.has_stack = MagicMock(return_value=False) + self.deployer._client.create_change_set = MagicMock( + side_effect=ClientError(error_response={"Error": {"Message": "Message"}}, operation_name="create_changeset") + ) + with self.assertRaises(ChangeSetError): + self.deployer.create_changeset( + stack_name="test", + cfn_template=" ", + parameter_values=[ + {"ParameterKey": "a", "ParameterValue": "b"}, + {"ParameterKey": "c", "UsePreviousValue": True}, + ], + capabilities=["CAPABILITY_IAM"], + role_arn="role-arn", + notification_arns=[], + s3_uploader=S3Uploader(s3_client=self.s3_client, bucket_name="test_bucket"), + tags={"unit": "true"}, + ) + def test_describe_changeset_with_changes(self): response = [ {