Skip to content

Commit aa228f8

Browse files
feat: add --express flag to sam delete (#9116)
* Add --express/--no-express flag to sam delete (#9115) * Include express in delete help formatter options * Support DeploymentConfig in CfnUtils.delete_stack * Add unit tests for express flag plumbing in sam delete * Add unit tests for delete_stack DeploymentConfig * test: add integration test for delete --express and regenerate schema - Add test_delete_express_mode: deploys a stack, deletes with --express, verifies express mode message and stack deletion - Add express parameter to get_delete_command_list helper - Regenerate schema/samcli.json to include express option for delete command --------- Co-authored-by: Roger Zhang <ruojiazh@amazon.com>
1 parent 8f25f26 commit aa228f8

9 files changed

Lines changed: 145 additions & 9 deletions

File tree

samcli/commands/delete/command.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,14 @@
6565
default=None,
6666
required=False,
6767
)
68+
@click.option(
69+
"--express/--no-express",
70+
default=False,
71+
required=False,
72+
is_flag=True,
73+
help="Use CloudFormation Express mode to speed up stack deletion by completing once resource "
74+
"deletion is initiated, without waiting for full cleanup.",
75+
)
6876
@aws_creds_options
6977
@common_options
7078
@save_params_option
@@ -79,6 +87,7 @@ def cli(
7987
no_prompts: bool,
8088
s3_bucket: str,
8189
s3_prefix: str,
90+
express: bool,
8291
config_env: str,
8392
config_file: str,
8493
save_params: bool,
@@ -95,6 +104,7 @@ def cli(
95104
no_prompts=no_prompts,
96105
s3_bucket=s3_bucket,
97106
s3_prefix=s3_prefix,
107+
express=express,
98108
) # pragma: no cover
99109

100110

@@ -105,6 +115,7 @@ def do_cli(
105115
no_prompts: bool,
106116
s3_bucket: Optional[str],
107117
s3_prefix: Optional[str],
118+
express: bool = False,
108119
):
109120
"""
110121
Implementation of the ``cli`` method
@@ -118,5 +129,6 @@ def do_cli(
118129
no_prompts=no_prompts,
119130
s3_bucket=s3_bucket,
120131
s3_prefix=s3_prefix,
132+
express=express,
121133
) as delete_context:
122134
delete_context.run()

samcli/commands/delete/core/options.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from samcli.cli.core.options import ALL_COMMON_OPTIONS, add_common_options_info
88
from samcli.cli.row_modifiers import RowDefinition
99

10-
DELETE_OPTION_NAMES = ["stack_name", "no_prompts", "s3_bucket", "s3_prefix"]
10+
DELETE_OPTION_NAMES = ["stack_name", "no_prompts", "s3_bucket", "s3_prefix", "express"]
1111
AWS_CREDENTIAL_OPTION_NAMES = ["region", "profile"]
1212
CONFIGURATION_OPTION_NAMES = ["config_file", "config_env", "save_params"]
1313

samcli/commands/delete/delete_context.py

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,13 +37,15 @@ def __init__(
3737
no_prompts: bool,
3838
s3_bucket: Optional[str],
3939
s3_prefix: Optional[str],
40+
express: bool = False,
4041
):
4142
self.stack_name = stack_name
4243
self.region = region
4344
self.profile = profile
4445
self.no_prompts = no_prompts
4546
self.s3_bucket = s3_bucket
4647
self.s3_prefix = s3_prefix
48+
self.express = express
4749
self.cf_utils = None
4850
self.s3_uploader = None
4951
self.ecr_uploader = None
@@ -211,15 +213,28 @@ def delete_ecr_companion_stack(self):
211213
try:
212214
# If delete_stack fails and its status changes to DELETE_FAILED, retain
213215
# the user input repositories and delete the stack.
214-
self.cf_utils.delete_stack(stack_name=self.companion_stack_name)
216+
self.cf_utils.delete_stack(
217+
stack_name=self.companion_stack_name, deployment_config=self._deployment_config
218+
)
215219
self.cf_utils.wait_for_delete(stack_name=self.companion_stack_name)
216220
LOG.debug("Deleted ECR Companion Stack: %s", self.companion_stack_name)
217221

218222
except CfDeleteFailedStatusError:
219223
LOG.debug("delete_stack resulted failed and so re-try with retain_resources")
220-
self.cf_utils.delete_stack(stack_name=self.companion_stack_name, retain_resources=retain_repos)
224+
self.cf_utils.delete_stack(
225+
stack_name=self.companion_stack_name,
226+
retain_resources=retain_repos,
227+
deployment_config=self._deployment_config,
228+
)
221229
self.cf_utils.wait_for_delete(stack_name=self.companion_stack_name)
222230

231+
@property
232+
def _deployment_config(self) -> Optional[dict]:
233+
"""CloudFormation DeploymentConfig for delete-stack calls, if express mode is enabled."""
234+
if self.express:
235+
return {"Mode": "EXPRESS"}
236+
return None
237+
223238
def delete(self):
224239
"""
225240
Delete method calls for Cloudformation stacks and S3 and ECR artifacts
@@ -275,13 +290,17 @@ def delete(self):
275290
# Delete the primary input stack
276291
try:
277292
click.echo(f"\t- Deleting Cloudformation stack {self.stack_name}")
278-
self.cf_utils.delete_stack(stack_name=self.stack_name)
293+
self.cf_utils.delete_stack(stack_name=self.stack_name, deployment_config=self._deployment_config)
279294
self.cf_utils.wait_for_delete(self.stack_name)
280295
LOG.debug("Deleted Cloudformation stack: %s", self.stack_name)
281296

282297
except CfDeleteFailedStatusError:
283298
LOG.debug("delete_stack resulted failed and so re-try with retain_resources")
284-
self.cf_utils.delete_stack(stack_name=self.stack_name, retain_resources=retain_resources)
299+
self.cf_utils.delete_stack(
300+
stack_name=self.stack_name,
301+
retain_resources=retain_resources,
302+
deployment_config=self._deployment_config,
303+
)
285304
self.cf_utils.wait_for_delete(self.stack_name)
286305

287306
# Warn the user that s3 information is missing and to use --s3 options
@@ -315,6 +334,12 @@ def run(self):
315334
LOG.debug("Input stack is deployed, continue deleting")
316335
self.delete()
317336
click.echo("\nDeleted successfully")
337+
if self.express:
338+
click.secho(
339+
"Deleted with CloudFormation Express mode. "
340+
"Some resources may still be removing in the background.",
341+
fg="yellow",
342+
)
318343
else:
319344
LOG.debug("Input stack does not exists on Cloudformation")
320345
click.echo(

samcli/lib/delete/cfn_utils.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import json
66
import logging
7-
from typing import List, Optional
7+
from typing import Dict, List, Optional
88

99
from botocore.exceptions import BotoCoreError, ClientError, WaiterError
1010

@@ -134,7 +134,12 @@ def get_stack_template(self, stack_name: str, stage: str) -> str:
134134
LOG.error("Unable to get stack details.", exc_info=e)
135135
raise e
136136

137-
def delete_stack(self, stack_name: str, retain_resources: Optional[List] = None):
137+
def delete_stack(
138+
self,
139+
stack_name: str,
140+
retain_resources: Optional[List] = None,
141+
deployment_config: Optional[Dict] = None,
142+
):
138143
"""
139144
Delete the Cloudformation stack with the given stack_name
140145
@@ -144,6 +149,8 @@ def delete_stack(self, stack_name: str, retain_resources: Optional[List] = None)
144149
str Name or ID of the stack
145150
retain_resources: Optional[List]
146151
List of repositories to retain if the stack has DELETE_FAILED status.
152+
deployment_config: Optional[Dict]
153+
CloudFormation DeploymentConfig, e.g. {"Mode": "EXPRESS"} for Express mode.
147154
148155
Raises
149156
------
@@ -152,8 +159,11 @@ def delete_stack(self, stack_name: str, retain_resources: Optional[List] = None)
152159
"""
153160
if not retain_resources:
154161
retain_resources = []
162+
kwargs: Dict = {"StackName": stack_name, "RetainResources": retain_resources}
163+
if deployment_config:
164+
kwargs["DeploymentConfig"] = deployment_config
155165
try:
156-
self._client.delete_stack(StackName=stack_name, RetainResources=retain_resources)
166+
self._client.delete_stack(**kwargs)
157167

158168
except (ClientError, BotoCoreError) as e:
159169
# If there are credentials, environment errors,

schema/samcli.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1514,7 +1514,7 @@
15141514
"properties": {
15151515
"parameters": {
15161516
"title": "Parameters for the delete command",
1517-
"description": "Available parameters for the delete command:\n* stack_name:\nThe name of the AWS CloudFormation stack you want to delete.\n* no_prompts:\nSpecify this flag to allow SAM CLI to skip through the guided prompts.\n* s3_bucket:\nThe S3 bucket path you want to delete.\n* s3_prefix:\nThe S3 prefix you want to delete\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.",
1517+
"description": "Available parameters for the delete command:\n* stack_name:\nThe name of the AWS CloudFormation stack you want to delete.\n* no_prompts:\nSpecify this flag to allow SAM CLI to skip through the guided prompts.\n* s3_bucket:\nThe S3 bucket path you want to delete.\n* s3_prefix:\nThe S3 prefix you want to delete\n* express:\nUse CloudFormation Express mode to speed up stack deletion by completing once resource deletion is initiated, without waiting for full cleanup.\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.",
15181518
"type": "object",
15191519
"properties": {
15201520
"stack_name": {
@@ -1537,6 +1537,11 @@
15371537
"type": "string",
15381538
"description": "The S3 prefix you want to delete"
15391539
},
1540+
"express": {
1541+
"title": "express",
1542+
"type": "boolean",
1543+
"description": "Use CloudFormation Express mode to speed up stack deletion by completing once resource deletion is initiated, without waiting for full cleanup."
1544+
},
15401545
"profile": {
15411546
"title": "profile",
15421547
"type": "string",

tests/integration/delete/delete_integ_base.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ def get_delete_command_list(
2020
no_prompts=None,
2121
s3_bucket=None,
2222
s3_prefix=None,
23+
express=None,
2324
):
2425
command_list = [get_sam_command(), "delete"]
2526

@@ -40,5 +41,9 @@ def get_delete_command_list(
4041
command_list += ["--s3-bucket", str(s3_bucket)]
4142
if s3_prefix:
4243
command_list += ["--s3-prefix", str(s3_prefix)]
44+
if express is True:
45+
command_list += ["--express"]
46+
elif express is False:
47+
command_list += ["--no-express"]
4348

4449
return command_list

tests/integration/delete/test_delete_command.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -554,6 +554,43 @@ def _validate_stack_deleted(self, stack_name: str) -> None:
554554
# - Create a stack using guided deploy of type image
555555
# - Select no for deleting ECR repository and this will retain the non-empty repository
556556

557+
@parameterized.expand(["aws-serverless-function.yaml"])
558+
def test_delete_express_mode(self, template_file):
559+
template_path = self.test_data_path.joinpath(template_file)
560+
561+
stack_name = self._method_to_stack_name(self.id())
562+
self.stacks.append({"name": stack_name})
563+
564+
deploy_command_list = self.get_deploy_command_list(
565+
template_file=template_path,
566+
stack_name=stack_name,
567+
capabilities="CAPABILITY_IAM",
568+
s3_prefix=self.s3_prefix,
569+
s3_bucket=self.s3_bucket.name,
570+
force_upload=True,
571+
notification_arns=self.sns_arn,
572+
parameter_overrides="Parameter=Clarity",
573+
kms_key_id=self.kms_key,
574+
no_execute_changeset=False,
575+
tags="integ=true clarity=yes foo_bar=baz",
576+
confirm_changeset=False,
577+
)
578+
579+
deploy_process = run_command(deploy_command_list)
580+
self.assertEqual(deploy_process.process.returncode, 0)
581+
582+
delete_command_list = self.get_delete_command_list(
583+
stack_name=stack_name,
584+
region=self._session.region_name,
585+
no_prompts=True,
586+
express=True,
587+
)
588+
589+
delete_process_execute = run_command(delete_command_list)
590+
self.assertEqual(delete_process_execute.process.returncode, 0)
591+
self.assertIn("Deleted with CloudFormation Express mode", delete_process_execute.stdout.decode())
592+
self._validate_stack_deleted(stack_name=stack_name)
593+
557594
def _method_to_stack_name(self, method_name):
558595
"""Method expects method name which can be a full path. Eg: test.integration.test_deploy_command.method_name"""
559596
method_name = method_name.split(".")[-1]

tests/unit/commands/delete/test_command.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,36 @@ def test_all_args(self, mock_delete_context, mock_delete_click):
3838
no_prompts=self.no_prompts,
3939
s3_bucket=self.s3_bucket,
4040
s3_prefix=self.s3_prefix,
41+
express=False,
42+
)
43+
44+
context_mock.run.assert_called_with()
45+
self.assertEqual(context_mock.run.call_count, 1)
46+
47+
@patch("samcli.commands.delete.command.click")
48+
@patch("samcli.commands.delete.delete_context.DeleteContext")
49+
def test_express_flag_is_passed_to_context(self, mock_delete_context, mock_delete_click):
50+
context_mock = Mock()
51+
mock_delete_context.return_value.__enter__.return_value = context_mock
52+
53+
do_cli(
54+
stack_name=self.stack_name,
55+
region=self.region,
56+
profile=self.profile,
57+
no_prompts=self.no_prompts,
58+
s3_bucket=self.s3_bucket,
59+
s3_prefix=self.s3_prefix,
60+
express=True,
61+
)
62+
63+
mock_delete_context.assert_called_with(
64+
stack_name=self.stack_name,
65+
region=self.region,
66+
profile=self.profile,
67+
no_prompts=self.no_prompts,
68+
s3_bucket=self.s3_bucket,
69+
s3_prefix=self.s3_prefix,
70+
express=True,
4171
)
4272

4373
context_mock.run.assert_called_with()

tests/unit/lib/delete/test_cfn_utils.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,18 @@ def test_cf_utils_delete_stack_exception(self):
109109
with self.assertRaises(Exception):
110110
self.cf_utils.delete_stack("test", ["retain_logical_id"])
111111

112+
def test_cf_utils_delete_stack_without_deployment_config(self):
113+
self.cf_utils._client.delete_stack = MagicMock()
114+
self.cf_utils.delete_stack("test")
115+
self.cf_utils._client.delete_stack.assert_called_once_with(StackName="test", RetainResources=[])
116+
117+
def test_cf_utils_delete_stack_with_express_deployment_config(self):
118+
self.cf_utils._client.delete_stack = MagicMock()
119+
self.cf_utils.delete_stack("test", deployment_config={"Mode": "EXPRESS"})
120+
self.cf_utils._client.delete_stack.assert_called_once_with(
121+
StackName="test", RetainResources=[], DeploymentConfig={"Mode": "EXPRESS"}
122+
)
123+
112124
def test_cf_utils_wait_for_delete_check_waiter_config(self):
113125
exception = WaiterError(
114126
name="wait_for_delete",

0 commit comments

Comments
 (0)