Skip to content

Commit

Permalink
feat: Delete methods for CF stacks and S3 files (#2981)
Browse files Browse the repository at this point in the history
* Added methods for cf and s3 files and init UI

* Added unit tests for utils methods and s3_uploader

* Removed s3_bucket and s3_prefix click options

* Fixed lint errors and added few unit tests

* Make black happy

* Added LOG statements

* Added and updated changes based on CR

* Fixed the unit tests in artifact_exporter.py

* Update HELP_TEXT in delete/command.py

Co-authored-by: Chris Rehn <[email protected]>

* Updated code based on Chris' comments

* Small changes and fixes based on the comments

* Removed region prompt

* Update SAM context values for profile and region in delete_context.py

* Added typing for get_cf_template_name method

* Added stack_name prompt if the stack_name is not present in samconfig file

* Replace [] with get() for stack-name in delete_context.py

Co-authored-by: Chris Rehn <[email protected]>
  • Loading branch information
hnnasit and Chris Rehn authored Jul 6, 2021
1 parent 3c54e78 commit d1f851b
Show file tree
Hide file tree
Showing 19 changed files with 642 additions and 27 deletions.
13 changes: 9 additions & 4 deletions samcli/cli/cli_config_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,14 @@ class TomlProvider:
A parser for toml configuration files
"""

def __init__(self, section=None):
def __init__(self, section=None, cmd_names=None):
"""
The constructor for TomlProvider class
:param section: section defined in the configuration file nested within `cmd`
:param cmd_names: cmd_name defined in the configuration file
"""
self.section = section
self.cmd_names = cmd_names

def __call__(self, config_path, config_env, cmd_names):
"""
Expand Down Expand Up @@ -67,18 +69,21 @@ def __call__(self, config_path, config_env, cmd_names):
LOG.debug("Config file '%s' does not exist", samconfig.path())
return resolved_config

if not self.cmd_names:
self.cmd_names = cmd_names

try:
LOG.debug(
"Loading configuration values from [%s.%s.%s] (env.command_name.section) in config file at '%s'...",
config_env,
cmd_names,
self.cmd_names,
self.section,
samconfig.path(),
)

# NOTE(TheSriram): change from tomlkit table type to normal dictionary,
# so that click defaults work out of the box.
resolved_config = dict(samconfig.get_all(cmd_names, self.section, env=config_env).items())
resolved_config = dict(samconfig.get_all(self.cmd_names, self.section, env=config_env).items())
LOG.debug("Configuration values successfully loaded.")
LOG.debug("Configuration values are: %s", resolved_config)

Expand All @@ -87,7 +92,7 @@ def __call__(self, config_path, config_env, cmd_names):
"Error reading configuration from [%s.%s.%s] (env.command_name.section) "
"in configuration file at '%s' with : %s",
config_env,
cmd_names,
self.cmd_names,
self.section,
samconfig.path(),
str(ex),
Expand Down
1 change: 1 addition & 0 deletions samcli/cli/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
"samcli.commands.local.local",
"samcli.commands.package",
"samcli.commands.deploy",
"samcli.commands.delete",
"samcli.commands.logs",
"samcli.commands.publish",
# We intentionally do not expose the `bootstrap` command for now. We might open it up later
Expand Down
6 changes: 6 additions & 0 deletions samcli/commands/delete/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
"""
`sam delete` command
"""

# Expose the cli object here
from .command import cli # noqa
90 changes: 90 additions & 0 deletions samcli/commands/delete/command.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
"""
CLI command for "delete" command
"""

import logging

import click
from samcli.cli.main import aws_creds_options, common_options, pass_context, print_cmdline_args

from samcli.lib.utils.version_checker import check_newer_version

SHORT_HELP = "Delete an AWS SAM application and the artifacts created by sam deploy."

HELP_TEXT = """The sam delete command deletes the CloudFormation
stack and all the artifacts which were created using sam deploy.
\b
e.g. sam delete
\b
"""

LOG = logging.getLogger(__name__)


@click.command(
"delete",
short_help=SHORT_HELP,
context_settings={"ignore_unknown_options": False, "allow_interspersed_args": True, "allow_extra_args": True},
help=HELP_TEXT,
)
@click.option(
"--stack-name",
required=False,
help="The name of the AWS CloudFormation stack you want to delete. ",
)
@click.option(
"--config-file",
help=(
"The path and file name of the configuration file containing default parameter values to use. "
"Its default value is 'samconfig.toml' in project directory. For more information about configuration files, "
"see: "
"https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-config.html."
),
type=click.STRING,
default="samconfig.toml",
show_default=True,
)
@click.option(
"--config-env",
help=(
"The environment name specifying the default parameter values in the configuration file to use. "
"Its default value is 'default'. For more information about configuration files, see: "
"https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-config.html."
),
type=click.STRING,
default="default",
show_default=True,
)
@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,
):
"""
`sam delete` command entry point
"""

# All logic must be implemented in the ``do_cli`` method. This helps with easy unit testing
do_cli(
stack_name=stack_name, region=ctx.region, config_file=config_file, config_env=config_env, profile=ctx.profile
) # pragma: no cover


def do_cli(stack_name: str, region: str, config_file: str, config_env: str, profile: str):
"""
Implementation of the ``cli`` method
"""
from samcli.commands.delete.delete_context import DeleteContext

with DeleteContext(
stack_name=stack_name, region=region, profile=profile, config_file=config_file, config_env=config_env
) as delete_context:
delete_context.run()
144 changes: 144 additions & 0 deletions samcli/commands/delete/delete_context.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
"""
Delete a SAM stack
"""

import boto3

import click
from click import confirm
from click import prompt
from samcli.cli.cli_config_file import TomlProvider
from samcli.lib.utils.botoconfig import get_boto_config_with_user_agent
from samcli.lib.delete.cf_utils import CfUtils
from samcli.lib.package.s3_uploader import S3Uploader
from samcli.lib.package.artifact_exporter import mktempfile, get_cf_template_name

CONFIG_COMMAND = "deploy"
CONFIG_SECTION = "parameters"
TEMPLATE_STAGE = "Original"


class DeleteContext:
def __init__(self, stack_name: str, region: str, profile: str, config_file: str, config_env: str):
self.stack_name = stack_name
self.region = region
self.profile = profile
self.config_file = config_file
self.config_env = config_env
self.s3_bucket = None
self.s3_prefix = None
self.cf_utils = None
self.s3_uploader = None
self.cf_template_file_name = None
self.delete_artifacts_folder = None
self.delete_cf_template_file = None

def __enter__(self):
self.parse_config_file()
if not self.stack_name:
self.stack_name = prompt(
click.style("\tEnter stack name you want to delete:", bold=True), type=click.STRING
)

return self

def __exit__(self, *args):
pass

def parse_config_file(self):
"""
Read the provided config file if it exists and assign the options values.
"""
toml_provider = TomlProvider(CONFIG_SECTION, [CONFIG_COMMAND])
config_options = toml_provider(
config_path=self.config_file, config_env=self.config_env, cmd_names=[CONFIG_COMMAND]
)
if config_options:
if not self.stack_name:
self.stack_name = config_options.get("stack_name", None)

# If the stack_name is same as the one present in samconfig file,
# get the information about parameters if not specified by customer.
if self.stack_name and self.stack_name == config_options.get("stack_name", None):
if not self.region:
self.region = config_options.get("region", None)
click.get_current_context().region = self.region
if not self.profile:
self.profile = config_options.get("profile", None)
click.get_current_context().profile = self.profile
self.s3_bucket = config_options.get("s3_bucket", None)
self.s3_prefix = config_options.get("s3_prefix", None)

def delete(self):
"""
Delete method calls for Cloudformation stacks and S3 and ECR artifacts
"""
template = self.cf_utils.get_stack_template(self.stack_name, TEMPLATE_STAGE)
template_str = template.get("TemplateBody", None)

if self.s3_bucket and self.s3_prefix and template_str:
self.delete_artifacts_folder = confirm(
click.style(
"\tAre you sure you want to delete the folder"
+ f" {self.s3_prefix} in S3 which contains the artifacts?",
bold=True,
),
default=False,
)
if not self.delete_artifacts_folder:
with mktempfile() as temp_file:
self.cf_template_file_name = get_cf_template_name(
temp_file=temp_file, template_str=template_str, extension="template"
)
self.delete_cf_template_file = confirm(
click.style(
"\tDo you want to delete the template file" + f" {self.cf_template_file_name} in S3?", bold=True
),
default=False,
)

# Delete the primary stack
self.cf_utils.delete_stack(stack_name=self.stack_name)

click.echo(f"\n\t- Deleting Cloudformation stack {self.stack_name}")

# Delete the CF template file in S3
if self.delete_cf_template_file:
self.s3_uploader.delete_artifact(remote_path=self.cf_template_file_name)

# Delete the folder of artifacts if s3_bucket and s3_prefix provided
elif self.delete_artifacts_folder:
self.s3_uploader.delete_prefix_artifacts()

def run(self):
"""
Delete the stack based on the argument provided by customers and samconfig.toml.
"""
delete_stack = confirm(
click.style(
f"\tAre you sure you want to delete the stack {self.stack_name}" + f" in the region {self.region} ?",
bold=True,
),
default=False,
)
# Fetch the template using the stack-name
if delete_stack and self.region:
boto_config = get_boto_config_with_user_agent()

# Define cf_client based on the region as different regions can have same stack-names
cloudformation_client = boto3.client(
"cloudformation", region_name=self.region if self.region else None, config=boto_config
)

s3_client = boto3.client("s3", region_name=self.region if self.region else None, config=boto_config)

self.s3_uploader = S3Uploader(s3_client=s3_client, bucket_name=self.s3_bucket, prefix=self.s3_prefix)
self.cf_utils = CfUtils(cloudformation_client)

is_deployed = self.cf_utils.has_stack(stack_name=self.stack_name)

if is_deployed:
self.delete()
click.echo("\nDeleted successfully")
else:
click.echo(f"Error: The input stack {self.stack_name} does not exist on Cloudformation")
24 changes: 24 additions & 0 deletions samcli/commands/delete/exceptions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
"""
Exceptions that are raised by sam delete
"""
from samcli.commands.exceptions import UserException


class DeleteFailedError(UserException):
def __init__(self, stack_name, msg):
self.stack_name = stack_name
self.msg = msg

message_fmt = "Failed to delete the stack: {stack_name}, {msg}"

super().__init__(message=message_fmt.format(stack_name=self.stack_name, msg=msg))


class FetchTemplateFailedError(UserException):
def __init__(self, stack_name, msg):
self.stack_name = stack_name
self.msg = msg

message_fmt = "Failed to fetch the template for the stack: {stack_name}, {msg}"

super().__init__(message=message_fmt.format(stack_name=self.stack_name, msg=msg))
Empty file added samcli/lib/delete/__init__.py
Empty file.
Loading

0 comments on commit d1f851b

Please sign in to comment.