diff --git a/samcli/commands/delete/command.py b/samcli/commands/delete/command.py index 06130eb68b..13700f3d12 100644 --- a/samcli/commands/delete/command.py +++ b/samcli/commands/delete/command.py @@ -4,6 +4,7 @@ import logging +from typing import Optional import click from samcli.cli.main import aws_creds_options, common_options, pass_context, print_cmdline_args @@ -63,12 +64,26 @@ is_flag=True, required=False, ) +@click.option( + "--s3-bucket", + help=("The S3 bucket path you want to delete."), + type=click.STRING, + default=None, + required=False, +) +@click.option( + "--s3-prefix", + help=("The S3 prefix you want to delete"), + type=click.STRING, + default=None, + required=False, +) @aws_creds_options @common_options @pass_context @check_newer_version @print_cmdline_args -def cli(ctx, stack_name: str, config_file: str, config_env: str, no_prompts: bool): +def cli(ctx, stack_name: str, config_file: str, config_env: str, no_prompts: bool, s3_bucket: str, s3_prefix: str): """ `sam delete` command entry point """ @@ -81,10 +96,21 @@ def cli(ctx, stack_name: str, config_file: str, config_env: str, no_prompts: boo config_env=config_env, profile=ctx.profile, no_prompts=no_prompts, + s3_bucket=s3_bucket, + s3_prefix=s3_prefix, ) # pragma: no cover -def do_cli(stack_name: str, region: str, config_file: str, config_env: str, profile: str, no_prompts: bool): +def do_cli( + stack_name: str, + region: str, + config_file: str, + config_env: str, + profile: str, + no_prompts: bool, + s3_bucket: Optional[str], + s3_prefix: Optional[str], +): """ Implementation of the ``cli`` method """ @@ -97,5 +123,7 @@ def do_cli(stack_name: str, region: str, config_file: str, config_env: str, prof config_file=config_file, config_env=config_env, no_prompts=no_prompts, + s3_bucket=s3_bucket, + s3_prefix=s3_prefix, ) as delete_context: delete_context.run() diff --git a/samcli/commands/delete/delete_context.py b/samcli/commands/delete/delete_context.py index f228580fd1..d060dbc681 100644 --- a/samcli/commands/delete/delete_context.py +++ b/samcli/commands/delete/delete_context.py @@ -4,8 +4,10 @@ import logging import json -import boto3 +from typing import Optional + +import boto3 import click from click import confirm @@ -36,15 +38,25 @@ class DeleteContext: # TODO: Separate this context into 2 separate contexts guided and non-guided, just like deploy. - def __init__(self, stack_name: str, region: str, profile: str, config_file: str, config_env: str, no_prompts: bool): + def __init__( + self, + stack_name: str, + region: str, + profile: str, + config_file: str, + config_env: str, + no_prompts: bool, + s3_bucket: Optional[str], + s3_prefix: Optional[str], + ): self.stack_name = stack_name self.region = region self.profile = profile self.config_file = config_file self.config_env = config_env self.no_prompts = no_prompts - self.s3_bucket = None - self.s3_prefix = None + self.s3_bucket = s3_bucket + self.s3_prefix = s3_prefix self.cf_utils = None self.s3_uploader = None self.ecr_uploader = None @@ -95,8 +107,10 @@ def parse_config_file(self): self.region = config_options.get("region", None) if not self.profile: self.profile = config_options.get("profile", None) - self.s3_bucket = config_options.get("s3_bucket", None) - self.s3_prefix = config_options.get("s3_prefix", None) + if not self.s3_bucket: + self.s3_bucket = config_options.get("s3_bucket", None) + if not self.s3_prefix: + self.s3_prefix = config_options.get("s3_prefix", None) def init_clients(self): """ @@ -142,8 +156,9 @@ def s3_prompts(self): Guided prompts asking user to delete s3 artifacts """ # Note: s3_bucket and s3_prefix information is only - # available if a local toml file is present or if - # this information is obtained from the template resources and so if this + # available if it is provided as an option flag, a + # local toml file or if this information is obtained + # from the template resources and so if this # information is not found, warn the user that S3 artifacts # will need to be manually deleted. @@ -319,12 +334,14 @@ def delete(self): self.cf_utils.delete_stack(stack_name=self.stack_name, retain_resources=retain_resources) self.cf_utils.wait_for_delete(self.stack_name) - # If s3_bucket information is not available, warn the user + # Warn the user that s3 information is missing and to use --s3 options if not self.s3_bucket: - LOG.debug("Cannot delete s3 files as no s3_bucket found") + LOG.debug("Cannot delete s3 objects as bucket is missing") click.secho( - "\nWarning: s3_bucket and s3_prefix information could not be obtained from local config file" - " or cloudformation template, delete the s3 files manually if required", + "\nWarning: Cannot resolve s3 bucket information from command options" + " , local config file or cloudformation template. Please use" + " --s3-bucket next time and" + " delete s3 files manually if required.", fg="yellow", ) diff --git a/tests/integration/delete/delete_integ_base.py b/tests/integration/delete/delete_integ_base.py index 5eb15810b3..e23a89075b 100644 --- a/tests/integration/delete/delete_integ_base.py +++ b/tests/integration/delete/delete_integ_base.py @@ -1,5 +1,6 @@ import os from pathlib import Path +from typing import Optional from unittest import TestCase @@ -22,21 +23,33 @@ def base_command(self): return command def get_delete_command_list( - self, stack_name=None, region=None, config_file=None, config_env=None, profile=None, no_prompts=None + self, + stack_name: Optional[str] = None, + region: Optional[str] = None, + config_file: Optional[str] = None, + config_env: Optional[str] = None, + profile: Optional[str] = None, + no_prompts: Optional[bool] = None, + s3_bucket: Optional[str] = None, + s3_prefix: Optional[str] = None, ): command_list = [self.base_command(), "delete"] if stack_name: - command_list += ["--stack-name", str(stack_name)] + command_list += ["--stack-name", stack_name] if region: - command_list += ["--region", str(region)] + command_list += ["--region", region] if config_file: - command_list += ["--config-file", str(config_file)] + command_list += ["--config-file", config_file] if config_env: - command_list += ["--config-env", str(config_env)] + command_list += ["--config-env", config_env] if profile: - command_list += ["--profile", str(profile)] + command_list += ["--profile", profile] if no_prompts: command_list += ["--no-prompts"] + if s3_bucket: + command_list += ["--s3-bucket", s3_bucket] + if s3_prefix: + command_list += ["--s3-prefix", s3_prefix] return command_list diff --git a/tests/integration/delete/test_delete_command.py b/tests/integration/delete/test_delete_command.py index d20535950f..4a953e4176 100644 --- a/tests/integration/delete/test_delete_command.py +++ b/tests/integration/delete/test_delete_command.py @@ -44,17 +44,72 @@ def setUpClass(cls): DeleteIntegBase.setUpClass() def setUp(self): - self.cf_client = boto3.client("cloudformation") + # Save reference to session object to get region_name + self._session = boto3.session.Session() + self.cf_client = self._session.client("cloudformation") + self.s3_client = self._session.client("s3") self.sns_arn = os.environ.get("AWS_SNS") time.sleep(CFN_SLEEP) super().setUp() + @parameterized.expand( + [ + "aws-serverless-function.yaml", + ] + ) + @pytest.mark.flaky(reruns=3) + def test_s3_options(self, template_file): + template_path = self.test_data_path.joinpath(template_file) + + stack_name = self._method_to_stack_name(self.id()) + + deploy_command_list = self.get_deploy_command_list( + template_file=template_path, + stack_name=stack_name, + capabilities="CAPABILITY_IAM", + image_repository=self.ecr_repo_name, + s3_bucket=self.bucket_name, + s3_prefix=self.s3_prefix, + force_upload=True, + notification_arns=self.sns_arn, + parameter_overrides="Parameter=Clarity", + kms_key_id=self.kms_key, + no_execute_changeset=False, + tags="integ=true clarity=yes foo_bar=baz", + confirm_changeset=False, + region=self._session.region_name, + ) + deploy_process_execute = run_command(deploy_command_list) + + delete_command_list = self.get_delete_command_list( + stack_name=stack_name, + region=self._session.region_name, + no_prompts=True, + s3_bucket=self.bucket_name, + s3_prefix=self.s3_prefix, + ) + delete_process_execute = run_command(delete_command_list) + + self.assertEqual(delete_process_execute.process.returncode, 0) + + # Check if the stack was deleted + try: + resp = self.cf_client.describe_stacks(StackName=stack_name) + except ClientError as ex: + self.assertIn(f"Stack with id {stack_name} does not exist", str(ex)) + + # Check for zero objects in bucket + s3_objects_resp = self.s3_client.list_objects_v2(Bucket=self.bucket_name, Prefix=self.s3_prefix) + self.assertEqual(s3_objects_resp["KeyCount"], 0) + @pytest.mark.flaky(reruns=3) def test_delete_command_no_stack_deployed(self): stack_name = self._method_to_stack_name(self.id()) - delete_command_list = self.get_delete_command_list(stack_name=stack_name, region="us-east-1", no_prompts=True) + delete_command_list = self.get_delete_command_list( + stack_name=stack_name, region=self._session.region_name, no_prompts=True + ) delete_process_execute = run_command(delete_command_list) self.assertEqual(delete_process_execute.process.returncode, 0) @@ -99,7 +154,7 @@ def test_delete_no_prompts_with_s3_prefix_present_zip(self, template_file): config_file_path = self.test_data_path.joinpath(config_file_name) delete_command_list = self.get_delete_command_list( - stack_name=stack_name, config_file=config_file_path, region="us-east-1", no_prompts=True + stack_name=stack_name, config_file=config_file_path, region=self._session.region_name, no_prompts=True ) delete_process_execute = run_command(delete_command_list) @@ -136,7 +191,7 @@ def test_delete_no_prompts_with_s3_prefix_present_image(self, template_file): config_file_path = self.test_data_path.joinpath(config_file_name) delete_command_list = self.get_delete_command_list( - stack_name=stack_name, config_file=config_file_path, region="us-east-1", no_prompts=True + stack_name=stack_name, config_file=config_file_path, region=self._session.region_name, no_prompts=True ) delete_process_execute = run_command(delete_command_list) @@ -204,7 +259,9 @@ def test_delete_no_config_file_zip(self, template_file): deploy_command_list, "{}\n\n\n\n\nn\n\n\n".format(stack_name).encode() ) - delete_command_list = self.get_delete_command_list(stack_name=stack_name, region="us-east-1", no_prompts=True) + delete_command_list = self.get_delete_command_list( + stack_name=stack_name, region=self._session.region_name, no_prompts=True + ) delete_process_execute = run_command(delete_command_list) self.assertEqual(delete_process_execute.process.returncode, 0) @@ -238,12 +295,14 @@ def test_delete_no_prompts_no_s3_prefix_zip(self, template_file): no_execute_changeset=False, tags="integ=true clarity=yes foo_bar=baz", confirm_changeset=False, - region="us-east-1", + region=self._session.region_name, ) deploy_process_execute = run_command(deploy_command_list) - delete_command_list = self.get_delete_command_list(stack_name=stack_name, region="us-east-1", no_prompts=True) + delete_command_list = self.get_delete_command_list( + stack_name=stack_name, region=self._session.region_name, no_prompts=True + ) delete_process_execute = run_command(delete_command_list) @@ -280,12 +339,14 @@ def test_delete_no_prompts_no_s3_prefix_image(self, template_file): no_execute_changeset=False, tags="integ=true clarity=yes foo_bar=baz", confirm_changeset=False, - region="us-east-1", + region=self._session.region_name, ) deploy_process_execute = run_command(deploy_command_list) - delete_command_list = self.get_delete_command_list(stack_name=stack_name, region="us-east-1", no_prompts=True) + delete_command_list = self.get_delete_command_list( + stack_name=stack_name, region=self._session.region_name, no_prompts=True + ) delete_process_execute = run_command(delete_command_list) @@ -325,7 +386,9 @@ def test_delete_nested_stacks(self, template_file): deploy_process_execute = run_command(deploy_command_list) - delete_command_list = self.get_delete_command_list(stack_name=stack_name, region="us-east-1", no_prompts=True) + delete_command_list = self.get_delete_command_list( + stack_name=stack_name, region=self._session.region_name, no_prompts=True + ) delete_process_execute = run_command(delete_command_list) @@ -352,7 +415,9 @@ def test_delete_stack_termination_protection_enabled(self): self.cf_client.create_stack(StackName=stack_name, TemplateBody=template_str, EnableTerminationProtection=True) - delete_command_list = self.get_delete_command_list(stack_name=stack_name, region="us-east-1", no_prompts=True) + delete_command_list = self.get_delete_command_list( + stack_name=stack_name, region=self._session.region_name, no_prompts=True + ) delete_process_execute = run_command(delete_command_list) @@ -414,7 +479,7 @@ def test_delete_guided_no_stack_name_no_region(self, template_file): no_execute_changeset=False, tags="integ=true clarity=yes foo_bar=baz", confirm_changeset=False, - region="us-east-1", + region=self._session.region_name, ) deploy_process_execute = run_command(deploy_command_list) @@ -451,11 +516,11 @@ def test_delete_guided_ecr_repository_present(self, template_file): no_execute_changeset=False, tags="integ=true clarity=yes foo_bar=baz", confirm_changeset=False, - region="us-east-1", + region=self._session.region_name, ) deploy_process_execute = run_command(deploy_command_list) - delete_command_list = self.get_delete_command_list(stack_name=stack_name, region="us-east-1") + delete_command_list = self.get_delete_command_list(stack_name=stack_name, region=self._session.region_name) delete_process_execute = run_command_with_input(delete_command_list, "y\ny\ny\n".encode()) self.assertEqual(delete_process_execute.process.returncode, 0) @@ -491,12 +556,12 @@ def test_delete_guided_no_s3_prefix_image(self, template_file): no_execute_changeset=False, tags="integ=true clarity=yes foo_bar=baz", confirm_changeset=False, - region="us-east-1", + region=self._session.region_name, ) deploy_process_execute = run_command(deploy_command_list) - delete_command_list = self.get_delete_command_list(stack_name=stack_name, region="us-east-1") + delete_command_list = self.get_delete_command_list(stack_name=stack_name, region=self._session.region_name) delete_process_execute = run_command_with_input(delete_command_list, "y\n".encode()) @@ -530,12 +595,12 @@ def test_delete_guided_retain_s3_artifact(self, template_file): no_execute_changeset=False, tags="integ=true clarity=yes foo_bar=baz", confirm_changeset=False, - region="us-east-1", + region=self._session.region_name, ) deploy_process_execute = run_command(deploy_command_list) self.add_left_over_resources_from_stack(stack_name) - delete_command_list = self.get_delete_command_list(stack_name=stack_name, region="us-east-1") + delete_command_list = self.get_delete_command_list(stack_name=stack_name, region=self._session.region_name) delete_process_execute = run_command_with_input(delete_command_list, "y\nn\nn\n".encode()) self.assertEqual(delete_process_execute.process.returncode, 0) diff --git a/tests/unit/commands/delete/test_command.py b/tests/unit/commands/delete/test_command.py index 7160553793..19f7542241 100644 --- a/tests/unit/commands/delete/test_command.py +++ b/tests/unit/commands/delete/test_command.py @@ -31,6 +31,8 @@ def test_all_args(self, mock_delete_context, mock_delete_click): config_env=self.config_env, profile=self.profile, no_prompts=self.no_prompts, + s3_bucket=self.s3_bucket, + s3_prefix=self.s3_prefix, ) mock_delete_context.assert_called_with( @@ -40,6 +42,8 @@ def test_all_args(self, mock_delete_context, mock_delete_click): config_file=self.config_file, config_env=self.config_env, no_prompts=self.no_prompts, + s3_bucket=self.s3_bucket, + s3_prefix=self.s3_prefix, ) context_mock.run.assert_called_with() diff --git a/tests/unit/commands/delete/test_delete_context.py b/tests/unit/commands/delete/test_delete_context.py index f4bc47d861..d14b88ebf9 100644 --- a/tests/unit/commands/delete/test_delete_context.py +++ b/tests/unit/commands/delete/test_delete_context.py @@ -26,6 +26,8 @@ def test_delete_context_stack_does_not_exist(self, patched_click_get_current_con config_env="default", profile="test", no_prompts=True, + s3_bucket=None, + s3_prefix=None, ) as delete_context: delete_context.run() @@ -44,6 +46,8 @@ def test_delete_context_enter(self): config_env="default", profile="test", no_prompts=True, + s3_bucket=None, + s3_prefix=None, ) as delete_context: self.assertEqual(delete_context.parse_config_file.call_count, 1) self.assertEqual(delete_context.init_clients.call_count, 1) @@ -73,6 +77,8 @@ def test_delete_context_parse_config_file(self, patched_click_get_current_contex config_env="default", profile=None, no_prompts=True, + s3_bucket=None, + s3_prefix=None, ) as delete_context: self.assertEqual(delete_context.stack_name, "test") self.assertEqual(delete_context.region, "us-east-1") @@ -93,6 +99,8 @@ def test_delete_no_user_input(self, patched_click_get_current_context, patched_c config_env=None, profile=None, no_prompts=None, + s3_bucket=None, + s3_prefix=None, ) as delete_context: delete_context.run() @@ -136,6 +144,8 @@ def test_delete_context_valid_execute_run(self, patched_click_get_current_contex config_env="default", profile=None, no_prompts=True, + s3_bucket=None, + s3_prefix=None, ) as delete_context: delete_context.run() @@ -163,13 +173,17 @@ def test_delete_context_no_s3_bucket( config_env="default", profile="test", no_prompts=True, + s3_bucket=None, + s3_prefix=None, ) as delete_context: delete_context.run() expected_click_secho_calls = [ call( - "\nWarning: s3_bucket and s3_prefix information could not be obtained from local config file" - " or cloudformation template, delete the s3 files manually if required", + "\nWarning: Cannot resolve s3 bucket information from command options" + " , local config file or cloudformation template. Please use" + " --s3-bucket next time and" + " delete s3 files manually if required.", fg="yellow", ), ] @@ -201,6 +215,8 @@ def test_guided_prompts_s3_bucket_prefix_present_execute_run( config_env="default", profile="test", no_prompts=None, + s3_bucket=None, + s3_prefix=None, ) as delete_context: patched_confirm.side_effect = [True, False, True] delete_context.s3_bucket = "s3_bucket" @@ -258,6 +274,8 @@ def test_guided_prompts_s3_bucket_present_no_prefix_execute_run( config_env="default", profile="test", no_prompts=None, + s3_bucket=None, + s3_prefix=None, ) as delete_context: patched_confirm.side_effect = [True, True] delete_context.s3_bucket = "s3_bucket" @@ -307,6 +325,8 @@ def test_guided_prompts_ecr_companion_stack_present_execute_run( config_env="default", profile="test", no_prompts=None, + s3_bucket=None, + s3_prefix=None, ) as delete_context: patched_confirm.side_effect = [True, False, True, True, True] delete_context.s3_bucket = "s3_bucket" @@ -384,6 +404,8 @@ def test_no_prompts_input_is_ecr_companion_stack_present_execute_run( config_env="default", profile="test", no_prompts=True, + s3_bucket=None, + s3_prefix=None, ) as delete_context: delete_context.s3_bucket = "s3_bucket" delete_context.s3_prefix = "s3_prefix" @@ -424,6 +446,8 @@ def test_retain_resources_delete_stack(self, patched_click_get_current_context, config_env="default", profile="test", no_prompts=True, + s3_bucket=None, + s3_prefix=None, ) as delete_context: delete_context.s3_bucket = "s3_bucket" delete_context.s3_prefix = "s3_prefix" @@ -434,3 +458,50 @@ def test_retain_resources_delete_stack(self, patched_click_get_current_context, self.assertEqual(CfnUtils.get_stack_template.call_count, 2) self.assertEqual(CfnUtils.delete_stack.call_count, 4) self.assertEqual(CfnUtils.wait_for_delete.call_count, 4) + + @patch.object(DeleteContext, "parse_config_file", MagicMock()) + @patch.object(DeleteContext, "init_clients", MagicMock()) + def test_s3_option_flag(self): + with DeleteContext( + stack_name="test", + region="us-east-1", + config_file="samconfig.toml", + config_env="default", + profile="test", + no_prompts=True, + s3_bucket="s3_bucket", + s3_prefix="s3_prefix", + ) as delete_context: + self.assertEqual(delete_context.s3_bucket, "s3_bucket") + self.assertEqual(delete_context.s3_prefix, "s3_prefix") + + @patch.object( + TomlProvider, + "__call__", + MagicMock( + return_value=( + { + "stack_name": "test", + "region": "us-east-1", + "profile": "developer", + "s3_bucket": "s3_bucket", + "s3_prefix": "s3_prefix", + } + ) + ), + ) + @patch.object(DeleteContext, "parse_config_file", MagicMock()) + @patch.object(DeleteContext, "init_clients", MagicMock()) + def test_s3_option_flag_overrides_config(self): + with DeleteContext( + stack_name="test", + region="us-east-1", + config_file="samconfig.toml", + config_env="default", + profile="test", + no_prompts=True, + s3_bucket="s3_bucket_override", + s3_prefix="s3_prefix_override", + ) as delete_context: + self.assertEqual(delete_context.s3_bucket, "s3_bucket_override") + self.assertEqual(delete_context.s3_prefix, "s3_prefix_override")