diff --git a/samcli/commands/deploy/command.py b/samcli/commands/deploy/command.py index c1d05561543..de8983e2806 100644 --- a/samcli/commands/deploy/command.py +++ b/samcli/commands/deploy/command.py @@ -110,6 +110,16 @@ is_flag=True, help="Prompt to confirm if the computed changeset is to be deployed by SAM CLI.", ) +@click.option( + "--save-params-on-failure/--no-save-params-on-failure", + default=False, + required=False, + is_flag=True, + help="Only applies to guided deploys (--guided). Save the arguments entered during the guided " + "prompts to the configuration file even when the deployment fails (e.g. due to invalid or expired " + "credentials), overwriting an existing configuration file if one is present. By default, arguments " + "are only saved on failure when no configuration file exists yet.", +) @click.option( "--disable-rollback/--no-disable-rollback", default=False, @@ -202,6 +212,7 @@ def cli( metadata, guided, confirm_changeset, + save_params_on_failure, signing_profiles, resolve_s3, resolve_image_repos, @@ -239,6 +250,7 @@ def cli( metadata, guided, confirm_changeset, + save_params_on_failure, ctx.region, ctx.profile, signing_profiles, @@ -275,6 +287,7 @@ def do_cli( metadata, guided, confirm_changeset, + save_params_on_failure, region, profile, signing_profiles, @@ -320,6 +333,7 @@ def do_cli( config_file=config_file, disable_rollback=disable_rollback, language_extensions_enabled=language_extensions_enabled, + force_save_config=save_params_on_failure, ) guided_context.run() else: diff --git a/samcli/commands/deploy/core/options.py b/samcli/commands/deploy/core/options.py index fbc38a0d75c..3781ea09fa2 100644 --- a/samcli/commands/deploy/core/options.py +++ b/samcli/commands/deploy/core/options.py @@ -34,6 +34,7 @@ "no_execute_changeset", "fail_on_empty_changeset", "confirm_changeset", + "save_params_on_failure", "disable_rollback", "on_failure", "force_upload", diff --git a/samcli/commands/deploy/guided_config.py b/samcli/commands/deploy/guided_config.py index 82a62642d5f..7b4e672c2a9 100644 --- a/samcli/commands/deploy/guided_config.py +++ b/samcli/commands/deploy/guided_config.py @@ -28,6 +28,14 @@ def get_config_ctx(self, config_file=None): ) return ctx, samconfig + def config_exists(self, config_file=None) -> bool: + """Return True if a configuration file already exists on disk.""" + try: + _, samconfig = self.get_config_ctx(config_file) + except SamConfigFileReadException: + return False + return bool(samconfig.exists()) + def read_config_showcase(self, config_file=None): msg = ( "Syntax invalid in samconfig.toml; save values " diff --git a/samcli/commands/deploy/guided_context.py b/samcli/commands/deploy/guided_context.py index 1a3335687ae..cea948559dc 100644 --- a/samcli/commands/deploy/guided_context.py +++ b/samcli/commands/deploy/guided_context.py @@ -63,6 +63,7 @@ def __init__( config_file=None, disable_rollback=None, language_extensions_enabled: bool = False, + force_save_config: bool = False, ): self.template_file = template_file self.stack_name = stack_name @@ -97,6 +98,7 @@ def __init__( self.function_provider: Optional[SamFunctionProvider] = None self.disable_rollback = disable_rollback self._language_extensions_enabled = language_extensions_enabled + self.force_save_config = force_save_config @property def guided_capabilities(self): @@ -183,6 +185,24 @@ def guided_prompts(self, parameter_override_keys): type=click.STRING, ) + # Persist all the answers the user has provided on the instance *before* making any AWS calls + # (manage_stack / sync_ecr_stack below). This way, if those calls fail (e.g. invalid or expired + # credentials), GuidedContext.run() can still save these answers to the configuration file so the + # user does not have to re-enter everything after fixing their credentials. + self.guided_stack_name = stack_name + self.guided_s3_prefix = stack_name + self.guided_region = region + self.guided_profile = self.profile + self._capabilities = input_capabilities if input_capabilities else default_capabilities + self._parameter_overrides = ( + input_parameter_overrides if input_parameter_overrides else self.parameter_overrides_from_cmdline + ) + self.save_to_config = save_to_config + self.config_env = config_env if config_env else default_config_env + self.config_file = config_file if config_file else default_config_file + self.confirm_changeset = confirm_changeset + self.disable_rollback = disable_rollback + click.echo("\n\tLooking for resources needed for deployment:") managed_s3_bucket = manage_stack(profile=self.profile, region=region) print_managed_s3_bucket_info(managed_s3_bucket) @@ -197,26 +217,12 @@ def guided_prompts(self, parameter_override_keys): ) ) - self.guided_stack_name = stack_name self.guided_s3_bucket = managed_s3_bucket self.guided_image_repositories = image_repositories # NOTE(sriram-mv): The resultant s3 bucket is ALWAYS the managed_s3_bucket. There is no user flow to set it # within guided. self.resolve_s3 = True if self.guided_s3_bucket else False - self.guided_s3_prefix = stack_name - self.guided_region = region - self.guided_profile = self.profile - self._capabilities = input_capabilities if input_capabilities else default_capabilities - self._parameter_overrides = ( - input_parameter_overrides if input_parameter_overrides else self.parameter_overrides_from_cmdline - ) - self.save_to_config = save_to_config - self.config_env = config_env if config_env else default_config_env - self.config_file = config_file if config_file else default_config_file - self.confirm_changeset = confirm_changeset - self.disable_rollback = disable_rollback - def prompt_authorization(self, stacks: List[Stack]): auth_required_per_resource = auth_per_resource(stacks) @@ -572,25 +578,49 @@ def run(self): self.config_file or DEFAULT_CONFIG_FILE_NAME, ) - self.guided_prompts(_parameter_override_keys) - - if self.save_to_config: - guided_config.save_config( - self._parameter_overrides, - self.config_env or DEFAULT_ENV, - self.config_file or DEFAULT_CONFIG_FILE_NAME, - stack_name=self.guided_stack_name, - resolve_s3=self.resolve_s3, - s3_prefix=self.guided_s3_prefix, - image_repositories=self.guided_image_repositories if not self.resolve_image_repositories else None, - resolve_image_repos=self.resolve_image_repositories, - region=self.guided_region, - profile=self.guided_profile, - confirm_changeset=self.confirm_changeset, - capabilities=self._capabilities, - signing_profiles=self.signing_profiles, - disable_rollback=self.disable_rollback, - ) + # Remember whether a configuration file already existed *before* we start prompting. This lets us + # decide, on failure, whether it is safe to persist the answers the user just entered. + config_already_exists = guided_config.config_exists(self.config_file or DEFAULT_CONFIG_FILE_NAME) + + # guided_prompts() collects the user's answers and then makes AWS calls (manage_stack / + # sync_ecr_stack) that require valid credentials. If those calls fail (e.g. invalid or expired + # credentials), we still want to persist the answers the user has already entered so they don't + # have to re-enter everything after fixing their credentials. All config-relevant answers are set + # on the instance before the AWS calls are made, so it is safe to save from the except branch. + # + # To avoid clobbering a known-good configuration during development, we only auto-save on failure + # when there was no pre-existing config file (i.e. a brand new project). If a config file already + # exists, the user must opt in via --save-params-on-failure to overwrite it on failure. + try: + self.guided_prompts(_parameter_override_keys) + except Exception: + if self._should_save_config() and (not config_already_exists or self.force_save_config): + self._save_config(guided_config) + raise + + if self._should_save_config(): + self._save_config(guided_config) + + def _should_save_config(self) -> bool: + return bool(self.save_to_config and self.guided_stack_name) + + def _save_config(self, guided_config): + guided_config.save_config( + self._parameter_overrides, + self.config_env or DEFAULT_ENV, + self.config_file or DEFAULT_CONFIG_FILE_NAME, + stack_name=self.guided_stack_name, + resolve_s3=self.resolve_s3, + s3_prefix=self.guided_s3_prefix, + image_repositories=self.guided_image_repositories if not self.resolve_image_repositories else None, + resolve_image_repos=self.resolve_image_repositories, + region=self.guided_region, + profile=self.guided_profile, + confirm_changeset=self.confirm_changeset, + capabilities=self._capabilities, + signing_profiles=self.signing_profiles, + disable_rollback=self.disable_rollback, + ) @staticmethod def _get_parameter_value( diff --git a/schema/samcli.json b/schema/samcli.json index 40498eb619e..dded0dbd964 100644 --- a/schema/samcli.json +++ b/schema/samcli.json @@ -1305,7 +1305,7 @@ "properties": { "parameters": { "title": "Parameters for the deploy command", - "description": "Available parameters for the deploy command:\n* guided:\nSpecify this flag to allow SAM CLI to guide you through the deployment using guided prompts.\n* template_file:\nAWS SAM template which references built artifacts for resources in the template. (if applicable)\n* no_execute_changeset:\nIndicates whether to execute the change set. Specify this flag to view stack changes before executing the change set.\n* fail_on_empty_changeset:\nSpecify whether AWS SAM CLI should return a non-zero exit code if there are no changes to be made to the stack. Defaults to a non-zero exit code.\n* confirm_changeset:\nPrompt to confirm if the computed changeset is to be deployed by SAM CLI.\n* disable_rollback:\nPreserves the state of previously provisioned resources when an operation fails.\n* on_failure:\nProvide an action to determine what will happen when a stack fails to create. Three actions are available:\n\n- ROLLBACK: This will rollback a stack to a previous known good state.\n\n- DELETE: The stack will rollback to a previous state if one exists, otherwise the stack will be deleted.\n\n- DO_NOTHING: The stack will not rollback or delete, this is the same as disabling rollback.\n\nDefault behaviour is ROLLBACK.\n\n\n\nThis option is mutually exclusive with --disable-rollback/--no-disable-rollback. You can provide\n--on-failure or --disable-rollback/--no-disable-rollback but not both at the same time.\n* max_wait_duration:\nMaximum duration in minutes to wait for the deployment to complete.\n* express:\nUse CloudFormation Express mode to speed up deployments by completing once resource configuration is applied, without waiting for full stabilization.\n* stack_name:\nName of the AWS CloudFormation stack.\n* s3_bucket:\nAWS S3 bucket where artifacts referenced in the template are uploaded.\n* image_repository:\nAWS ECR repository URI where artifacts referenced in the template are uploaded.\n* image_repositories:\nMapping of Function Logical ID to AWS ECR Repository URI.\n\nExample: Function_Logical_ID=ECR_Repo_Uri\nThis option can be specified multiple times.\n* force_upload:\nIndicates whether to override existing files in the S3 bucket. Specify this flag to upload artifacts even if they match existing artifacts in the S3 bucket.\n* s3_prefix:\nPrefix name that is added to the artifact's name when it is uploaded to the AWS S3 bucket.\n* kms_key_id:\nThe ID of an AWS KMS key that is used to encrypt artifacts that are at rest in the AWS S3 bucket.\n* role_arn:\nARN of an IAM role that AWS Cloudformation assumes when executing a deployment change set.\n* use_json:\nIndicates whether to use JSON as the format for the output AWS CloudFormation template. YAML is used by default.\n* resolve_s3:\nAutomatically resolve AWS S3 bucket for non-guided deployments. Enabling this option will also create a managed default AWS S3 bucket for you. If one does not provide a --s3-bucket value, the managed bucket will be used. Do not use --guided with this option.\n* resolve_image_repos:\nAutomatically create and delete ECR repositories for image-based functions in non-guided deployments. A companion stack containing ECR repos for each function will be deployed along with the template stack. Automatically created image repositories will be deleted if the corresponding functions are removed.\n* metadata:\nMap of metadata to attach to ALL the artifacts that are referenced in the template.\n* notification_arns:\nARNs of SNS topics that AWS Cloudformation associates with the stack.\n* tags:\nList of tags to associate with the stack.\n* parameter_overrides:\nString that contains AWS CloudFormation parameter overrides encoded as key=value pairs.\n* signing_profiles:\nA string that contains Code Sign configuration parameters as FunctionOrLayerNameToSign=SigningProfileName:SigningProfileOwner Since signing profile owner is optional, it could also be written as FunctionOrLayerNameToSign=SigningProfileName\n* no_progressbar:\nDoes not showcase a progress bar when uploading artifacts to S3 and pushing docker images to ECR\n* capabilities:\nList of capabilities that one must specify before AWS Cloudformation can create certain stacks.\n\nAccepted Values: CAPABILITY_IAM, CAPABILITY_NAMED_IAM, CAPABILITY_RESOURCE_POLICY, CAPABILITY_AUTO_EXPAND.\n\nLearn more at: https://docs.aws.amazon.com/serverlessrepo/latest/devguide/acknowledging-application-capabilities.html\n* language_extensions:\nExpand AWS::LanguageExtensions transforms (Fn::ForEach, Fn::Length, Fn::ToJsonString, Fn::FindInMap with DefaultValue) locally before running SAM transforms. Off by default. Equivalent env var: SAM_CLI_ENABLE_LANGUAGE_EXTENSIONS=1.\n* profile:\nSelect a specific profile from your credential file to get AWS credentials.\n* region:\nSet the AWS Region of the service. (e.g. us-east-1)\n* beta_features:\nEnable/Disable beta features.\n* debug:\nTurn on debug logging to print debug message generated by AWS SAM CLI and display timestamps.\n* save_params:\nSave the parameters provided via the command line to the configuration file.", + "description": "Available parameters for the deploy command:\n* guided:\nSpecify this flag to allow SAM CLI to guide you through the deployment using guided prompts.\n* template_file:\nAWS SAM template which references built artifacts for resources in the template. (if applicable)\n* no_execute_changeset:\nIndicates whether to execute the change set. Specify this flag to view stack changes before executing the change set.\n* fail_on_empty_changeset:\nSpecify whether AWS SAM CLI should return a non-zero exit code if there are no changes to be made to the stack. Defaults to a non-zero exit code.\n* confirm_changeset:\nPrompt to confirm if the computed changeset is to be deployed by SAM CLI.\n* save_params_on_failure:\nOnly applies to guided deploys (--guided). Save the arguments entered during the guided prompts to the configuration file even when the deployment fails (e.g. due to invalid or expired credentials), overwriting an existing configuration file if one is present. By default, arguments are only saved on failure when no configuration file exists yet.\n* disable_rollback:\nPreserves the state of previously provisioned resources when an operation fails.\n* on_failure:\nProvide an action to determine what will happen when a stack fails to create. Three actions are available:\n\n- ROLLBACK: This will rollback a stack to a previous known good state.\n\n- DELETE: The stack will rollback to a previous state if one exists, otherwise the stack will be deleted.\n\n- DO_NOTHING: The stack will not rollback or delete, this is the same as disabling rollback.\n\nDefault behaviour is ROLLBACK.\n\n\n\nThis option is mutually exclusive with --disable-rollback/--no-disable-rollback. You can provide\n--on-failure or --disable-rollback/--no-disable-rollback but not both at the same time.\n* max_wait_duration:\nMaximum duration in minutes to wait for the deployment to complete.\n* express:\nUse CloudFormation Express mode to speed up deployments by completing once resource configuration is applied, without waiting for full stabilization.\n* stack_name:\nName of the AWS CloudFormation stack.\n* s3_bucket:\nAWS S3 bucket where artifacts referenced in the template are uploaded.\n* image_repository:\nAWS ECR repository URI where artifacts referenced in the template are uploaded.\n* image_repositories:\nMapping of Function Logical ID to AWS ECR Repository URI.\n\nExample: Function_Logical_ID=ECR_Repo_Uri\nThis option can be specified multiple times.\n* force_upload:\nIndicates whether to override existing files in the S3 bucket. Specify this flag to upload artifacts even if they match existing artifacts in the S3 bucket.\n* s3_prefix:\nPrefix name that is added to the artifact's name when it is uploaded to the AWS S3 bucket.\n* kms_key_id:\nThe ID of an AWS KMS key that is used to encrypt artifacts that are at rest in the AWS S3 bucket.\n* role_arn:\nARN of an IAM role that AWS Cloudformation assumes when executing a deployment change set.\n* use_json:\nIndicates whether to use JSON as the format for the output AWS CloudFormation template. YAML is used by default.\n* resolve_s3:\nAutomatically resolve AWS S3 bucket for non-guided deployments. Enabling this option will also create a managed default AWS S3 bucket for you. If one does not provide a --s3-bucket value, the managed bucket will be used. Do not use --guided with this option.\n* resolve_image_repos:\nAutomatically create and delete ECR repositories for image-based functions in non-guided deployments. A companion stack containing ECR repos for each function will be deployed along with the template stack. Automatically created image repositories will be deleted if the corresponding functions are removed.\n* metadata:\nMap of metadata to attach to ALL the artifacts that are referenced in the template.\n* notification_arns:\nARNs of SNS topics that AWS Cloudformation associates with the stack.\n* tags:\nList of tags to associate with the stack.\n* parameter_overrides:\nString that contains AWS CloudFormation parameter overrides encoded as key=value pairs.\n* signing_profiles:\nA string that contains Code Sign configuration parameters as FunctionOrLayerNameToSign=SigningProfileName:SigningProfileOwner Since signing profile owner is optional, it could also be written as FunctionOrLayerNameToSign=SigningProfileName\n* no_progressbar:\nDoes not showcase a progress bar when uploading artifacts to S3 and pushing docker images to ECR\n* capabilities:\nList of capabilities that one must specify before AWS Cloudformation can create certain stacks.\n\nAccepted Values: CAPABILITY_IAM, CAPABILITY_NAMED_IAM, CAPABILITY_RESOURCE_POLICY, CAPABILITY_AUTO_EXPAND.\n\nLearn more at: https://docs.aws.amazon.com/serverlessrepo/latest/devguide/acknowledging-application-capabilities.html\n* language_extensions:\nExpand AWS::LanguageExtensions transforms (Fn::ForEach, Fn::Length, Fn::ToJsonString, Fn::FindInMap with DefaultValue) locally before running SAM transforms. Off by default. Equivalent env var: SAM_CLI_ENABLE_LANGUAGE_EXTENSIONS=1.\n* profile:\nSelect a specific profile from your credential file to get AWS credentials.\n* region:\nSet the AWS Region of the service. (e.g. us-east-1)\n* beta_features:\nEnable/Disable beta features.\n* debug:\nTurn on debug logging to print debug message generated by AWS SAM CLI and display timestamps.\n* save_params:\nSave the parameters provided via the command line to the configuration file.", "type": "object", "properties": { "guided": { @@ -1335,6 +1335,11 @@ "type": "boolean", "description": "Prompt to confirm if the computed changeset is to be deployed by SAM CLI." }, + "save_params_on_failure": { + "title": "save_params_on_failure", + "type": "boolean", + "description": "Only applies to guided deploys (--guided). Save the arguments entered during the guided prompts to the configuration file even when the deployment fails (e.g. due to invalid or expired credentials), overwriting an existing configuration file if one is present. By default, arguments are only saved on failure when no configuration file exists yet." + }, "disable_rollback": { "title": "disable_rollback", "type": "boolean", diff --git a/tests/unit/commands/deploy/test_command.py b/tests/unit/commands/deploy/test_command.py index 8a008b56d3d..ce031523544 100644 --- a/tests/unit/commands/deploy/test_command.py +++ b/tests/unit/commands/deploy/test_command.py @@ -43,6 +43,7 @@ def setUp(self): self.metadata = {} self.guided = False self.confirm_changeset = False + self.save_params_on_failure = False self.resolve_s3 = False self.config_env = "mock-default-env" self.config_file = "mock-default-filename" @@ -98,6 +99,7 @@ def test_all_args(self, mock_deploy_context, mock_deploy_click, mock_package_con metadata=self.metadata, guided=self.guided, confirm_changeset=self.confirm_changeset, + save_params_on_failure=self.save_params_on_failure, signing_profiles=self.signing_profiles, resolve_s3=self.resolve_s3, config_env=self.config_env, @@ -220,6 +222,7 @@ def test_all_args_guided_no_to_authorization_confirmation_prompt( metadata=self.metadata, guided=True, confirm_changeset=True, + save_params_on_failure=self.save_params_on_failure, signing_profiles=self.signing_profiles, resolve_s3=self.resolve_s3, config_env=self.config_env, @@ -324,6 +327,7 @@ def test_all_args_guided_use_defaults( metadata=self.metadata, guided=True, confirm_changeset=True, + save_params_on_failure=self.save_params_on_failure, signing_profiles=self.signing_profiles, resolve_s3=self.resolve_s3, config_env=self.config_env, @@ -474,6 +478,7 @@ def test_all_args_guided( metadata=self.metadata, guided=True, confirm_changeset=True, + save_params_on_failure=self.save_params_on_failure, signing_profiles=self.signing_profiles, resolve_s3=self.resolve_s3, config_env=self.config_env, @@ -627,6 +632,7 @@ def test_all_args_guided_no_save_echo_param_to_config( metadata=self.metadata, guided=True, confirm_changeset=True, + save_params_on_failure=self.save_params_on_failure, signing_profiles=self.signing_profiles, resolve_s3=self.resolve_s3, config_env=self.config_env, @@ -792,6 +798,7 @@ def test_all_args_guided_no_params_save_config( metadata=self.metadata, guided=True, confirm_changeset=True, + save_params_on_failure=self.save_params_on_failure, resolve_s3=self.resolve_s3, config_env=self.config_env, config_file=self.config_file, @@ -937,6 +944,7 @@ def test_all_args_guided_no_params_no_save_config( metadata=self.metadata, guided=True, confirm_changeset=True, + save_params_on_failure=self.save_params_on_failure, resolve_s3=self.resolve_s3, config_file=self.config_file, config_env=self.config_env, @@ -1019,6 +1027,7 @@ def test_all_args_resolve_s3( metadata=self.metadata, guided=self.guided, confirm_changeset=self.confirm_changeset, + save_params_on_failure=self.save_params_on_failure, resolve_s3=True, config_file=self.config_file, config_env=self.config_env, @@ -1089,6 +1098,7 @@ def test_resolve_s3_and_s3_bucket_both_set(self): metadata=self.metadata, guided=False, confirm_changeset=True, + save_params_on_failure=self.save_params_on_failure, resolve_s3=True, config_file=self.config_file, config_env=self.config_env, @@ -1143,6 +1153,7 @@ def test_all_args_resolve_image_repos( metadata=self.metadata, guided=self.guided, confirm_changeset=self.confirm_changeset, + save_params_on_failure=self.save_params_on_failure, resolve_s3=False, config_file=self.config_file, config_env=self.config_env, @@ -1222,6 +1233,7 @@ def test_passing_parameter_overrides_to_context( metadata=self.metadata, guided=self.guided, confirm_changeset=self.confirm_changeset, + save_params_on_failure=self.save_params_on_failure, signing_profiles=self.signing_profiles, resolve_s3=self.resolve_s3, config_env=self.config_env, diff --git a/tests/unit/commands/deploy/test_guided_context.py b/tests/unit/commands/deploy/test_guided_context.py index 052d8a84a76..e713b27b91a 100644 --- a/tests/unit/commands/deploy/test_guided_context.py +++ b/tests/unit/commands/deploy/test_guided_context.py @@ -1002,3 +1002,177 @@ def test_guided_prompts_check_default_config_region( ), ] self.assertEqual(expected_prompt_calls, patched_prompt.call_args_list) + + @patch("samcli.commands.deploy.guided_context.get_template_parameters") + @patch("samcli.commands.deploy.guided_context.GuidedConfig") + @patch("samcli.commands.deploy.guided_context.get_resource_full_path_by_id") + @patch("samcli.commands.deploy.guided_context.prompt") + @patch("samcli.commands.deploy.guided_context.confirm") + @patch("samcli.commands.deploy.guided_context.manage_stack") + @patch("samcli.commands.deploy.guided_context.auth_per_resource") + @patch("samcli.commands.deploy.guided_context.SamLocalStackProvider.get_stacks") + @patch("samcli.commands.deploy.guided_context.SamFunctionProvider") + @patch("samcli.commands.deploy.guided_context.signer_config_per_function") + def test_run_saves_config_when_manage_stack_fails( + self, + patched_signer_config_per_function, + patched_sam_function_provider, + patched_get_buildable_stacks, + patched_auth_per_resource, + patched_manage_stack, + patched_confirm, + patched_prompt, + patched_get_resource_full_path_by_id, + patched_guided_config, + patched_get_template_parameters, + ): + # New project (no existing samconfig): the user answers all the prompts, but manage_stack (the + # first AWS call requiring credentials) fails, e.g. because of invalid/expired credentials. + patched_get_template_parameters.return_value = {} + patched_sam_function_provider.return_value.functions = {} + patched_get_buildable_stacks.return_value = (Mock(), []) + patched_auth_per_resource.return_value = [("HelloWorldFunction", True)] + patched_signer_config_per_function.return_value = ({}, {}) + patched_prompt.side_effect = ["my-stack", "us-west-2", "samconfig.toml", "default"] + # Confirm changeset, allow IAM, disable rollback, save to config + patched_confirm.side_effect = [True, True, False, True] + + credentials_error = Exception("The security token included in the request is invalid.") + patched_manage_stack.side_effect = credentials_error + + guided_config_instance = patched_guided_config.return_value + # No pre-existing configuration file. + guided_config_instance.config_exists.return_value = False + + # The original error should still propagate up ... + with self.assertRaises(Exception) as ctx: + self.gc.run() + self.assertIs(ctx.exception, credentials_error) + + # ... but the configuration file must have been saved with the answers already collected. + guided_config_instance.save_config.assert_called_once() + _, save_kwargs = guided_config_instance.save_config.call_args + self.assertEqual(save_kwargs["stack_name"], "my-stack") + self.assertEqual(save_kwargs["region"], "us-west-2") + + @patch("samcli.commands.deploy.guided_context.get_template_parameters") + @patch("samcli.commands.deploy.guided_context.GuidedConfig") + @patch("samcli.commands.deploy.guided_context.get_resource_full_path_by_id") + @patch("samcli.commands.deploy.guided_context.prompt") + @patch("samcli.commands.deploy.guided_context.confirm") + @patch("samcli.commands.deploy.guided_context.manage_stack") + @patch("samcli.commands.deploy.guided_context.auth_per_resource") + @patch("samcli.commands.deploy.guided_context.SamLocalStackProvider.get_stacks") + @patch("samcli.commands.deploy.guided_context.SamFunctionProvider") + @patch("samcli.commands.deploy.guided_context.signer_config_per_function") + def test_run_does_not_overwrite_existing_config_on_failure_by_default( + self, + patched_signer_config_per_function, + patched_sam_function_provider, + patched_get_buildable_stacks, + patched_auth_per_resource, + patched_manage_stack, + patched_confirm, + patched_prompt, + patched_get_resource_full_path_by_id, + patched_guided_config, + patched_get_template_parameters, + ): + # A samconfig already exists and the user has NOT opted in via --save-params-on-failure. + # A failure must NOT overwrite the known-good existing configuration. + patched_get_template_parameters.return_value = {} + patched_sam_function_provider.return_value.functions = {} + patched_get_buildable_stacks.return_value = (Mock(), []) + patched_auth_per_resource.return_value = [("HelloWorldFunction", True)] + patched_signer_config_per_function.return_value = ({}, {}) + patched_prompt.side_effect = ["my-stack", "us-west-2", "samconfig.toml", "default"] + patched_confirm.side_effect = [True, True, False, True] + + credentials_error = Exception("The security token included in the request is invalid.") + patched_manage_stack.side_effect = credentials_error + + guided_config_instance = patched_guided_config.return_value + # A configuration file already exists. + guided_config_instance.config_exists.return_value = True + + self.gc.force_save_config = False + + with self.assertRaises(Exception) as ctx: + self.gc.run() + self.assertIs(ctx.exception, credentials_error) + + guided_config_instance.save_config.assert_not_called() + + @patch("samcli.commands.deploy.guided_context.get_template_parameters") + @patch("samcli.commands.deploy.guided_context.GuidedConfig") + @patch("samcli.commands.deploy.guided_context.get_resource_full_path_by_id") + @patch("samcli.commands.deploy.guided_context.prompt") + @patch("samcli.commands.deploy.guided_context.confirm") + @patch("samcli.commands.deploy.guided_context.manage_stack") + @patch("samcli.commands.deploy.guided_context.auth_per_resource") + @patch("samcli.commands.deploy.guided_context.SamLocalStackProvider.get_stacks") + @patch("samcli.commands.deploy.guided_context.SamFunctionProvider") + @patch("samcli.commands.deploy.guided_context.signer_config_per_function") + def test_run_overwrites_existing_config_on_failure_when_forced( + self, + patched_signer_config_per_function, + patched_sam_function_provider, + patched_get_buildable_stacks, + patched_auth_per_resource, + patched_manage_stack, + patched_confirm, + patched_prompt, + patched_get_resource_full_path_by_id, + patched_guided_config, + patched_get_template_parameters, + ): + # A samconfig already exists AND the user opted in via --save-params-on-failure. + # A failure SHOULD save (overwrite) the configuration. + patched_get_template_parameters.return_value = {} + patched_sam_function_provider.return_value.functions = {} + patched_get_buildable_stacks.return_value = (Mock(), []) + patched_auth_per_resource.return_value = [("HelloWorldFunction", True)] + patched_signer_config_per_function.return_value = ({}, {}) + patched_prompt.side_effect = ["my-stack", "us-west-2", "samconfig.toml", "default"] + patched_confirm.side_effect = [True, True, False, True] + + credentials_error = Exception("The security token included in the request is invalid.") + patched_manage_stack.side_effect = credentials_error + + guided_config_instance = patched_guided_config.return_value + guided_config_instance.config_exists.return_value = True + + self.gc.force_save_config = True + + with self.assertRaises(Exception) as ctx: + self.gc.run() + self.assertIs(ctx.exception, credentials_error) + + guided_config_instance.save_config.assert_called_once() + + @patch("samcli.commands.deploy.guided_context.get_template_parameters") + @patch("samcli.commands.deploy.guided_context.GuidedConfig") + @patch("samcli.commands.deploy.guided_context.prompt") + @patch("samcli.commands.deploy.guided_context.SamLocalStackProvider.get_stacks") + @patch("samcli.commands.deploy.guided_context.SamFunctionProvider") + def test_run_does_not_save_config_when_aborted_before_stack_name( + self, + patched_sam_function_provider, + patched_get_buildable_stacks, + patched_prompt, + patched_guided_config, + patched_get_template_parameters, + ): + # The user aborts (Ctrl+C) at the very first prompt, before providing any answers. + patched_get_template_parameters.return_value = {} + patched_sam_function_provider.return_value.functions = {} + patched_get_buildable_stacks.return_value = (Mock(), []) + patched_prompt.side_effect = click.exceptions.Abort() + + guided_config_instance = patched_guided_config.return_value + + with self.assertRaises(click.exceptions.Abort): + self.gc.run() + + # No stack name was collected, so nothing should be saved. + guided_config_instance.save_config.assert_not_called()