Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Binary canary config #5720

Merged
merged 14 commits into from
Aug 5, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 5 additions & 3 deletions appveyor-linux-binary.yml
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,11 @@ install:
- sh: "PATH=$(echo $PWD'/aws_cli/bin'):$PATH"

# Install pytest
- sh: "pip3 install -r requirements/pre-dev.txt"
- sh: "pip3 install -r requirements/dev.txt"
- sh: "pip3 install -r requirements/base.txt"
- sh: "virtualenv pytest"
- sh: "./pytest/bin/python -m pip install -r requirements/pre-dev.txt"
- sh: "./pytest/bin/python -m pip install -r requirements/dev.txt"
- sh: "./pytest/bin/python -m pip install -r requirements/base.txt"
- sh: "PATH=$(echo $PWD'/pytest/bin'):$PATH"
- sh: "pytest --version"

- sh: "PATH=$PATH:$HOME/venv3.7/bin:$HOME/venv3.8/bin:$HOME/venv3.9/bin:$HOME/venv3.10/bin"
Expand Down
26 changes: 13 additions & 13 deletions samcli/commands/delete/delete_context.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
"""
Delete a SAM stack
"""
import json
import logging
from typing import Optional

Expand Down Expand Up @@ -132,6 +131,11 @@ def init_clients(self):
self.uploaders = Uploaders(self.s3_uploader, self.ecr_uploader)
self.cf_utils = CfnUtils(cloudformation_client)

# Set region, this is purely for logging purposes
# the cloudformation client is able to read from
# the configuration file to get the region
self.region = self.region or cloudformation_client.meta.config.region_name

def s3_prompts(self):
"""
Guided prompts asking user to delete s3 artifacts
Expand Down Expand Up @@ -218,16 +222,16 @@ def delete_ecr_companion_stack(self):
"""
delete_ecr_companion_stack_prompt = self.ecr_companion_stack_prompts()
if delete_ecr_companion_stack_prompt or self.no_prompts:
cf_ecr_companion_stack = self.cf_utils.get_stack_template(self.companion_stack_name, TEMPLATE_STAGE)
ecr_stack_template_str = cf_ecr_companion_stack.get("TemplateBody", None)
ecr_stack_template_str = json.dumps(ecr_stack_template_str, indent=4, ensure_ascii=False)
cf_ecr_companion_stack_template = self.cf_utils.get_stack_template(
self.companion_stack_name, TEMPLATE_STAGE
)

ecr_companion_stack_template = Template(
template_path=None,
parent_dir=None,
uploaders=self.uploaders,
code_signer=None,
template_str=ecr_stack_template_str,
template_str=cf_ecr_companion_stack_template,
)

retain_repos = self.ecr_repos_prompts(ecr_companion_stack_template)
Expand All @@ -253,20 +257,16 @@ def delete(self):
"""
# Fetch the template using the stack-name
cf_template = self.cf_utils.get_stack_template(self.stack_name, TEMPLATE_STAGE)
template_str = cf_template.get("TemplateBody", None)

if isinstance(template_str, dict):
template_str = json.dumps(template_str, indent=4, ensure_ascii=False)

# Get the cloudformation template name using template_str
self.cf_template_file_name = get_uploaded_s3_object_name(file_content=template_str, extension="template")
self.cf_template_file_name = get_uploaded_s3_object_name(file_content=cf_template, extension="template")

template = Template(
template_path=None,
parent_dir=None,
uploaders=self.uploaders,
code_signer=None,
template_str=template_str,
template_str=cf_template,
)

# If s3 info is not available, try to obtain it from CF
Expand All @@ -286,7 +286,7 @@ def delete(self):
# ECR companion stack delete prompts, if it exists
companion_stack = CompanionStack(self.stack_name)

ecr_companion_stack_exists = self.cf_utils.has_stack(stack_name=companion_stack.stack_name)
ecr_companion_stack_exists = self.cf_utils.can_delete_stack(stack_name=companion_stack.stack_name)
if ecr_companion_stack_exists:
LOG.debug("ECR Companion stack found for the input stack")
self.companion_stack_name = companion_stack.stack_name
Expand Down Expand Up @@ -340,7 +340,7 @@ def run(self):
)

if self.no_prompts or delete_stack:
is_deployed = self.cf_utils.has_stack(stack_name=self.stack_name)
is_deployed = self.cf_utils.can_delete_stack(stack_name=self.stack_name)
# Check if the provided stack-name exists
if is_deployed:
LOG.debug("Input stack is deployed, continue deleting")
Expand Down
36 changes: 36 additions & 0 deletions samcli/commands/delete/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,39 @@ def __init__(self, stack_name, msg):
message = f"Failed to fetch the template for the stack: {stack_name}, {msg}"

super().__init__(message=message)


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

message = f"Failed to fetch change sets for stack: {stack_name}, {msg}"

super().__init__(message=message)


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

message = f"Stack {stack_name} does not contain any change sets"

super().__init__(message=message)


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

message = f"Failed to complete an API call to fetch stack information for {stack_name}: {msg}"
super().__init__(message=message)


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

message = f"Stack {stack_name} cannot be deleted while TerminationProtection is enabled."
super().__init__(message=message)
164 changes: 137 additions & 27 deletions samcli/lib/delete/cfn_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,19 @@
"""

import logging
from typing import Dict, List, Optional
from typing import List, Optional

from botocore.exceptions import BotoCoreError, ClientError, WaiterError

from samcli.commands.delete.exceptions import CfDeleteFailedStatusError, DeleteFailedError, FetchTemplateFailedError
from samcli.commands.delete.exceptions import (
CfDeleteFailedStatusError,
DeleteFailedError,
FetchChangeSetError,
FetchTemplateFailedError,
NoChangeSetFoundError,
StackFetchError,
StackProtectionEnabledError,
)

LOG = logging.getLogger(__name__)

Expand All @@ -16,12 +24,26 @@ class CfnUtils:
def __init__(self, cloudformation_client):
self._client = cloudformation_client

def has_stack(self, stack_name: str) -> bool:
def can_delete_stack(self, stack_name: str) -> bool:
"""
Checks if a CloudFormation stack with given name exists

:param stack_name: Name or ID of the stack
:return: True if stack exists. False otherwise
Parameters
----------
stack_name: str
Name or ID of the stack

Returns
-------
bool
True if stack exists. False otherwise

Raises
------
StackFetchError
Raised when the boto call fails to get stack information
StackProtectionEnabledError
Raised when the stack is protected from deletions
"""
try:
resp = self._client.describe_stacks(StackName=stack_name)
Expand All @@ -30,14 +52,9 @@ def has_stack(self, stack_name: str) -> bool:

stack = resp["Stacks"][0]
if stack["EnableTerminationProtection"]:
message = "Stack cannot be deleted while TerminationProtection is enabled."
raise DeleteFailedError(stack_name=stack_name, msg=message)
raise StackProtectionEnabledError(stack_name=stack_name)

# Note: Stacks with REVIEW_IN_PROGRESS can be deleted
# using delete_stack but get_template does not return
# the template_str for this stack restricting deletion of
# artifacts.
return bool(stack["StackStatus"] != "REVIEW_IN_PROGRESS")
return True

except ClientError as e:
# If a stack does not exist, describe_stacks will throw an
Expand All @@ -48,35 +65,64 @@ def has_stack(self, stack_name: str) -> bool:
LOG.debug("Stack with id %s does not exist", stack_name)
return False
LOG.error("ClientError Exception : %s", str(e))
raise DeleteFailedError(stack_name=stack_name, msg=str(e)) from e
raise StackFetchError(stack_name=stack_name, msg=str(e)) from e
except BotoCoreError as e:
# If there are credentials, environment errors,
# catch that and throw a delete failed error.

LOG.error("Botocore Exception : %s", str(e))
raise DeleteFailedError(stack_name=stack_name, msg=str(e)) from e
raise StackFetchError(stack_name=stack_name, msg=str(e)) from e

def get_stack_template(self, stack_name: str, stage: str) -> Dict:
def get_stack_template(self, stack_name: str, stage: str) -> str:
"""
Return the Cloudformation template of the given stack_name

:param stack_name: Name or ID of the stack
:param stage: The Stage of the template Original or Processed
:return: Template body of the stack
Parameters
----------

stack_name: str
Name or ID of the stack
stage: str
The Stage of the template Original or Processed

Returns
-------
str
Template body of the stack

Raises
------
FetchTemplateFailedError
Raised when boto calls or parsing fails to fetch template
"""
try:
resp = self._client.get_template(StackName=stack_name, TemplateStage=stage)
if not resp["TemplateBody"]:
return {}
return dict(resp)
template = resp.get("TemplateBody", "")

# stack may not have template, check the change set
if not template:
change_set_name = self._get_change_set_name(stack_name)

if change_set_name:
# the stack has a change set, use the template from this
resp = self._client.get_template(
StackName=stack_name, TemplateStage=stage, ChangeSetName=change_set_name
)
template = resp.get("TemplateBody", "")

return str(template)

except (ClientError, BotoCoreError) as e:
# If there are credentials, environment errors,
# catch that and throw a delete failed error.

LOG.error("Failed to fetch template for the stack : %s", str(e))
raise FetchTemplateFailedError(stack_name=stack_name, msg=str(e)) from e

except FetchChangeSetError as ex:
raise FetchTemplateFailedError(stack_name=stack_name, msg=str(ex)) from ex
except NoChangeSetFoundError as ex:
msg = "Failed to find a change set to fetch the template"
raise FetchTemplateFailedError(stack_name=stack_name, msg=msg) from ex
except Exception as e:
# We don't know anything about this exception. Don't handle
LOG.error("Unable to get stack details.", exc_info=e)
Expand All @@ -86,8 +132,17 @@ def delete_stack(self, stack_name: str, retain_resources: Optional[List] = None)
"""
Delete the Cloudformation stack with the given stack_name

:param stack_name: Name or ID of the stack
:param retain_resources: List of repositories to retain if the stack has DELETE_FAILED status.
Parameters
----------
stack_name:
str Name or ID of the stack
retain_resources: Optional[List]
List of repositories to retain if the stack has DELETE_FAILED status.

Raises
------
DeleteFailedError
Raised when the boto delete_stack call fails
"""
if not retain_resources:
retain_resources = []
Expand All @@ -106,11 +161,21 @@ def delete_stack(self, stack_name: str, retain_resources: Optional[List] = None)
LOG.error("Failed to delete stack. ", exc_info=e)
raise e

def wait_for_delete(self, stack_name):
def wait_for_delete(self, stack_name: str):
"""
Waits until the delete stack completes

:param stack_name: Stack name
Parameter
---------
stack_name: str
The name of the stack to watch when deleting

Raises
------
CfDeleteFailedStatusError
Raised when the stack fails to delete
DeleteFailedError
Raised when the stack fails to wait when polling for status
"""

# Wait for Delete to Finish
Expand All @@ -121,11 +186,56 @@ def wait_for_delete(self, stack_name):
try:
waiter.wait(StackName=stack_name, WaiterConfig=waiter_config)
except WaiterError as ex:
stack_status = ex.last_response.get("Stacks", [{}])[0].get("StackStatusReason", "")
stack_status = ex.last_response.get("Stacks", [{}])[0].get("StackStatusReason", "") # type: ignore

if "DELETE_FAILED" in str(ex):
raise CfDeleteFailedStatusError(
stack_name=stack_name, stack_status=stack_status, msg="ex: {0}".format(ex)
) from ex

raise DeleteFailedError(stack_name=stack_name, stack_status=stack_status, msg="ex: {0}".format(ex)) from ex

def _get_change_set_name(self, stack_name: str) -> str:
"""
Returns the name of the change set for a stack

Parameters
----------
stack_name: str
The name of the stack to find a change set

Returns
-------
str
The name of a change set

Raises
------
FetchChangeSetError
Raised if there are boto call errors or parsing errors
NoChangeSetFoundError
Raised if a stack does not have any change sets
"""
try:
change_sets: dict = self._client.list_change_sets(StackName=stack_name)
except (ClientError, BotoCoreError) as ex:
LOG.debug("Failed to perform boto call to fetch change sets")
raise FetchChangeSetError(stack_name=stack_name, msg=str(ex)) from ex

change_sets = change_sets.get("Summaries", [])

if len(change_sets) > 1:
LOG.info(
"More than one change set was found, please clean up any "
"lingering template files that may exist in the S3 bucket."
)

if len(change_sets) > 0:
change_set = change_sets[0]
change_set_name = str(change_set.get("ChangeSetName", ""))

LOG.debug(f"Returning change set: {change_set}")
return change_set_name

LOG.debug("Stack contains no change sets")
raise NoChangeSetFoundError(stack_name=stack_name)
Loading
Loading