"""
:mod:`kingpin.actors.aws.cloudformation`
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
"""
import datetime
import json
import logging
import re
import uuid
from hashlib import md5
from json import JSONEncoder
import boto3
from botocore.exceptions import ClientError
from tornado import concurrent, gen, ioloop
from kingpin import utils
from kingpin.actors import exceptions
from kingpin.actors.aws import base
from kingpin.actors.aws.settings import (
KINGPIN_CFN_DEFAULT_ROLE_ARN,
KINGPIN_CFN_HASH_OUTPUT_KEY,
)
from kingpin.actors.utils import dry
from kingpin.constants import REQUIRED, STATE, SchemaCompareBase, StringCompareBase
log = logging.getLogger(__name__)
__author__ = "Matt Wise <matt@nextdoor.com>"
[docs]
class DateEncoder(JSONEncoder):
[docs]
def default(self, obj):
if isinstance(obj, (datetime.date)):
return obj.isoformat()
# This executor is used by the tornado.concurrent.run_on_executor() decorator.
# We would like this to be a class variable so its shared across objects, but we
# see testing IO errors when we do this.
EXECUTOR = concurrent.futures.ThreadPoolExecutor(10)
S3_REGEX = re.compile(r"s3://(?P<bucket>[a-z0-9.-]+)/(?P<key>.*)")
[docs]
class StackFailed(exceptions.RecoverableActorFailure):
"""Raised any time a Stack fails to be created or updated."""
[docs]
class InvalidTemplate(exceptions.UnrecoverableActorFailure):
"""An invalid CloudFormation template was supplied."""
[docs]
class StackAlreadyExists(exceptions.RecoverableActorFailure):
"""The requested CloudFormation stack already exists."""
[docs]
class StackNotFound(exceptions.RecoverableActorFailure):
"""The requested CloudFormation stack does not exist."""
[docs]
class ParametersConfig(SchemaCompareBase):
"""Validates the Parameters option.
A valid `parameters` option is a dictionary with simple Key/Value pairs of
strings. No nested dicts, arrays or other objects.
"""
SCHEMA = {
"type": ["object", "null"],
"uniqueItems": True,
"patternProperties": {".*": {"type": "string"}},
}
[docs]
class CapabilitiesConfig(SchemaCompareBase):
"""Validates the Capabilities option"""
SCHEMA = {
"type": ["array", "null"],
"uniqueItems": True,
"items": {
"type": "string",
"enum": [
"CAPABILITY_IAM",
"CAPABILITY_NAMED_IAM",
"CAPABILITY_AUTO_EXPAND",
],
},
}
[docs]
class OnFailureConfig(StringCompareBase):
"""Validates the On Failure option.
The `on_failure` option can take one of the following settings:
`DO_NOTHING`, `ROLLBACK`, `DELETE`
This option is applied at stack _creation_ time!
"""
valid = ("DO_NOTHING", "ROLLBACK", "DELETE")
[docs]
class TerminationProtectionConfig(StringCompareBase):
"""Validates the TerminationProtectionConfig option.
The `enable_termination_protection` option can take one of the following
settings: `"UNCHANGED"`, `False`, `True`
`"UNCHANGED"` means on Create Stack it will default to False, however on
Ensure Stack no changes will be applied.
"""
valid = ("UNCHANGED", True, False)
# CloudFormation has over a dozen different 'stack states'... but for the
# purposes of these actors, we really only care about a few logical states. Here
# we map the raw states into logical states.
COMPLETE = (
"CREATE_COMPLETE",
"UPDATE_COMPLETE",
"UPDATE_ROLLBACK_COMPLETE",
"IMPORT_COMPLETE",
"IMPORT_ROLLBACK_COMPLETE",
)
DELETED = ("DELETE_COMPLETE",)
IN_PROGRESS = (
"CREATE_PENDING",
"CREATE_IN_PROGRESS",
"DELETE_IN_PROGRESS",
"EXECUTE_IN_PROGRESS",
"ROLLBACK_IN_PROGRESS",
"UPDATE_COMPLETE_CLEANUP_IN_PROGRESS",
"UPDATE_IN_PROGRESS",
"UPDATE_ROLLBACK_COMPLETE_CLEANUP_IN_PROGRESS",
"UPDATE_ROLLBACK_IN_PROGRESS",
"IMPORT_IN_PROGRESS",
"IMPORT_ROLLBACK_IN_PROGRESS",
)
FAILED = (
"CREATE_FAILED",
"DELETE_FAILED",
"ROLLBACK_FAILED",
"UPDATE_ROLLBACK_FAILED",
"ROLLBACK_COMPLETE",
"IMPORT_ROLLBACK_FAILED",
)
[docs]
class Create(CloudFormationBaseActor):
"""Creates a CloudFormation stack.
Creates a CloudFormation stack from scratch and waits until the stack is
fully built before exiting the actor.
**Options**
:name:
The name of the queue to create
:capabilities:
A list of CFN capabilities to add to the stack.
:on_failure:
(:py:class:`OnFailureConfig`)
One of the following strings: `DO_NOTHING`, `ROLLBACK`, `DELETE`
Default: `DELETE`
:parameters:
A dictionary of key/value pairs used to fill in the parameters for the
CloudFormation template.
:region:
AWS region (or zone) string, like 'us-west-2'.
:role_arn:
The Amazon IAM Role to use when executing the stack.
:template:
String of path to CloudFormation template. Can either be in the form of
a local file path (ie, `./my_template.json`) or a URI (ie
`s3://bucket-name/cfn.json`).
:timeout_in_minutes:
The amount of time that can pass before the stack status becomes
CREATE_FAILED.
:enable_termination_protection:
Whether termination protection is enabled for the stack.
**Examples**
.. code-block:: json
{
"actor": "aws.cloudformation.Create",
"desc": "Create production backend stack",
"options": {
"capabilities": [ "CAPABILITY_IAM" ],
"name": "%CFN_NAME%",
"parameters": {
"test_param": "%TEST_PARAM_NAME%",
},
"region": "us-west-1",
"role_arn": "arn:aws:iam::123456789012:role/DeployRole",
"template": "/examples/cloudformation_test.json",
"timeout_in_minutes": 45,
"enable_termination_protection": true,
}
}
**Dry Mode**
Validates the template, verifies that an existing stack with that name does
not exist. Does not create the stack.
"""
all_options = {
"capabilities": (
list,
[],
"The list of capabilities that you want to allow in the stack",
),
"on_failure": (
OnFailureConfig,
"DELETE",
"Action to take if the stack fails to be created",
),
"name": (str, REQUIRED, "Name of the stack"),
"parameters": (
ParametersConfig,
{},
"Parameters passed into the CFN template execution",
),
"region": (str, REQUIRED, "AWS region (or zone) name, like us-west-2"),
"role_arn": (
str,
None,
"The Amazon IAM Role to use when executing the stack. You can also set the KINGPIN_CFN_ROLE_ARN env var if you are managing many stacks.",
),
"template": (
str,
REQUIRED,
"Path to the AWS CloudFormation File. s3://, file:///, absolute or relative file paths.",
),
"template_s3_region": (str, None, "Region of the bucket containing template"),
"timeout_in_minutes": (
int,
60,
"The amount of time that can pass before the stack status becomes CREATE_FAILED",
),
"enable_termination_protection": (
TerminationProtectionConfig,
"UNCHANGED",
"Whether termination protection is enabled for the stack.",
),
}
desc = "Creating CloudFormation Stack {name}"
def __init__(self, *args, **kwargs):
"""Initialize our object variables."""
super().__init__(*args, **kwargs)
# Convert our supplied parameters into a properly formatted list.
self._parameters = self._create_parameters(self.option("parameters"))
# Check if the supplied CFN template is a local file. If it is, read it
# into memory.
self._template_body, self._template_url = self._get_template_body(
self.option("template"),
self.option("template_s3_region"),
)
@gen.coroutine
def _execute(self):
stack_name = self.option("name")
yield self._validate_template(self._template_body, self._template_url)
# If a stack already exists, we cannot re-create it. Raise a recoverable
# exception and let the end user decide whether this is bad or not.
exists = yield self._get_stack(stack_name)
if exists:
raise StackAlreadyExists(f"Stack {stack_name} already exists!")
# If we're in dry mode, exit at this point. We can't do anything further
# to validate that the creation process will work.
if self._dry:
self.log.info("Skipping CloudFormation Stack creation.")
raise gen.Return()
# Create the stack
yield self._create_stack(stack=stack_name)
raise gen.Return()
[docs]
class Delete(CloudFormationBaseActor):
"""Deletes a CloudFormation stack
**Options**
:name:
The name of the queue to create
:region:
AWS region (or zone) string, like 'us-west-2'
**Examples**
.. code-block:: json
{
"desc": "Delete production backend stack",
"actor": "aws.cloudformation.Create",
"options" {
"region": "us-west-1",
"name": "%CFN_NAME%",
}
}
**Dry Mode**
Validates that the CFN stack exists, but does not delete it.
"""
all_options = {
"name": (str, REQUIRED, "Name of the stack"),
"region": (str, REQUIRED, "AWS region (or zone) name, like us-west-2"),
"role_arn": (
str,
None,
"The Amazon IAM Role to use when executing the stack. You can also set the KINGPIN_CFN_ROLE_ARN env var if you are managing many stacks.",
),
}
desc = "Deleting CloudFormation Stack {name}"
@gen.coroutine
def _execute(self):
stack_name = self.option("name")
yield self._delete_stack(stack=stack_name)
[docs]
class Stack(CloudFormationBaseActor):
"""Manages the state of a CloudFormation stack.
This actor can manage the following aspects of a CloudFormation stack in
Amazon:
* Ensure that the Stack is present or absent.
* Monitor and update the stack Template and Parameters as necessary.
**Default Parameters**
If your CFN stack defines parameters with defaults, Kingpin will use the
defaults unless the parameters are explicitly specified.
**NoEcho Parameters**
If your CFN stack takes a Password as a parameter or any other value thats
secret and you set `NoEcho: True` on that parameter, Kingpin will be unable
to diff it and compare whether or not the desired setting matches whats in
Amazon. A warning will be thrown, and the rest of the actor will continue
to operate as normal.
If any other difference triggers a Stack Update, the desired value for the
parameter with `NoEcho: True` will be pushed in addition to all of the other
stack parameters.
**Options**
:name:
The name of the queue to create
:state:
(str) Present or Absent. Default: "present"
:capabilities:
(:py:class:`CapabilitiesConfig`, None)
A list of CFN capabilities to add to the stack.
:disable_rollback:
Set to True to disable rollback of the stack if creation failed.
:on_failure:
(:py:class:`OnFailureConfig`, None)
One of the following strings: `DO_NOTHING`, `ROLLBACK`, `DELETE`
Default: `DELETE`
:parameters:
(:py:class:`ParametersConfig`, None)
A dictionary of key/value pairs used to fill in the parameters for the
CloudFormation template.
:region:
AWS region (or zone) string, like 'us-west-2'.
:role_arn:
The Amazon IAM Role to use when executing the stack.
:template:
String of path to CloudFormation template. Can either be in the form of
a local file path (ie, `./my_template.json`) or a URI (ie
`s3://bucket-name/cfn.json`).
:timeout_in_minutes:
The amount of time that can pass before the stack status becomes
CREATE_FAILED.
:enable_termination_protection:
Whether termination protection is enabled for the stack.
**Examples**
.. code-block:: json
{
"actor": "aws.cloudformation.Stack",
"desc": "Manages the state of a CloudFormation stack",
"options": {
"capabilities": [ "CAPABILITY_IAM" ],
"on_failure": "DELETE",
"name": "%CFN_NAME%",
"parameters": {
"test_param": "%TEST_PARAM_NAME%",
},
"region": "us-west-1",
"role_arn": "arn:aws:iam::123456789012:role/DeployRole",
"state": "present",
"template": "/examples/cloudformation_test.json",
"timeout_in_minutes": 45,
"enable_termination_protection": true,
}
}
**Dry Mode**
Validates the template, verifies that an existing stack with that name does
not exist. Does not create the stack.
"""
all_options = {
"name": (str, REQUIRED, "Name of the stack"),
"state": (STATE, "present", "Desired state of the bucket: present/absent"),
"capabilities": (
CapabilitiesConfig,
[],
"The list of capabilities that you want to allow in the stack",
),
"disable_rollback": (
bool,
False,
"Set to `True` to disable rollback of the stack if stack creation failed.",
),
"on_failure": (
OnFailureConfig,
"DELETE",
"Action to take if the stack fails to be created",
),
"parameters": (
ParametersConfig,
{},
"Parameters passed into the CFN template execution",
),
"region": (str, REQUIRED, "AWS region (or zone) name, like us-west-2"),
"role_arn": (
str,
None,
"The Amazon IAM Role to use when executing the stack. You can also set the KINGPIN_CFN_ROLE_ARN env var if you are managing many stacks.",
),
"template": (
str,
REQUIRED,
"Path to the AWS CloudFormation File. s3://, file:///, absolute or relative file paths.",
),
"template_s3_region": (str, None, "Region of the bucket containing template"),
"timeout_in_minutes": (
int,
60,
"The amount of time that can pass before the stack status becomes CREATE_FAILED",
),
"enable_termination_protection": (
TerminationProtectionConfig,
"UNCHANGED",
"Whether termination protection is enabled for the stack.",
),
}
desc = "CloudFormation Stack {name}"
def __init__(self, *args, **kwargs):
"""Initialize our object variables."""
super().__init__(*args, **kwargs)
# Check if the supplied CFN template is a local file. If it is, read it
# into memory.
self._template_body, self._template_url = self._get_template_body(
self.option("template"),
self.option("template_s3_region"),
)
# Find any Default parameters embedded in the stack.
_default_params = self._discover_default_params(self._template_body)
# Convert Default parameters and our supplied parameters into a properly
# formatted list. Defaults will be overridden by supplied parameters.
self._parameters = self._create_parameters(
dict(_default_params, **self.option("parameters"))
)
# Discover whether or not there are any NoEcho parameters embedded in
# the stack. If there are, record them locally and throw a warning to
# the user about it.
self._noecho_params = self._discover_noecho_params(self._template_body)
for p in self._noecho_params:
self.log.warning(
f'Parameter "{p}" has NoEcho set to True. '
f"Will not use in parameter comparison."
)
@gen.coroutine
def _update_stack(self, stack):
self.log.info("Verifying that stack is in desired state")
# First, check that this stack isn't one that may have failed before and
# there was attempted to be deleted but failed. If it is, we have a
# fatal error and we must raise an exception.
if stack["StackStatus"] == "DELETE_FAILED":
msg = f"Stack found in a deleted failed state: {stack['StackStatus']}"
raise StackFailed(msg)
# Upon a stack creation, there are two states the stack can be left in
# that are both un-fixable -- CREATE_FAILED and ROLLBACK_COMPLETE. In
# both of these cases, the only possible option is to destroy the stack
# and re-create it, you cannot fix a broken stack.
if stack["StackStatus"] in ("CREATE_FAILED", "ROLLBACK_COMPLETE"):
self.log.warning(f"Stack found in a failed state: {stack['StackStatus']}")
yield self._delete_stack(stack=stack["StackId"])
yield self._create_stack(stack=stack["StackName"])
raise gen.Return()
# Compare the live and new EnableTerminationProtection parameter and
# update it if it is different.
yield self._ensure_termination_protection(stack)
# Pull down the live stack template and compare it to the one we have
# locally.
yield self._ensure_template(stack)
@gen.coroutine
def _ensure_template(self, stack):
"""Compares and updates the state of a CFN Stack template
Compares the current template body against the template body for the
live running stack. If they're different. Triggers a Change Set creation
and ultimately executes the change set.
TODO: Support remote template_url comparison!
Args:
stack: A Boto3 Stack object
"""
needs_update = False
# Get the current template for the stack, and get our local template
# body. Make sure they're in the same form (dict).
existing = yield self._get_stack_template(stack["StackId"])
new = json.loads(self._template_body)
# Compare the two templates. If they differ at all, log it out for the
# user and flip the needs_update bit.
diff = utils.diff_dicts(existing, new)
if diff:
self.log.warning("Stack templates do not match.")
for line in diff.split("\n"):
self.log.info(f"Diff: {line}")
# Plan to make a change set!
needs_update = True
# Get and compare the parameters we have vs the ones in CFN. If they're
# different, plan to do an update!
if self._diff_params_safely(stack.get("Parameters", []), self._parameters):
needs_update = True
# If needs_update isn't set, then the templates are the same and we can
# bail!
if not needs_update:
self.log.debug("Stack matches configuration, no changes necessary")
raise gen.Return()
# If we're here, the templates have diverged. Generate the change set,
# log out the changes, and execute them.
change_set_req = yield self._create_change_set(stack)
change_set = yield self._wait_until_change_set_ready(
change_set_req["Id"], "Status", "CREATE_COMPLETE"
)
self._print_change_set(change_set)
# Ok run the change set itself!
try:
yield self._execute_change_set(change_set_name=change_set_req["Id"])
except (ClientError, StackFailed) as e:
raise StackFailed(e) from e
# In dry mode, delete our change set so we don't leave it around as
# cruft. THis isn't necessary in the real run, because the changeset
# cannot be deleted once its been applied.
if self._dry:
yield self.api_call(
self.cfn_conn.delete_change_set, ChangeSetName=change_set_req["Id"]
)
self.log.info("Done updating template")
def _diff_params_safely(self, remote, local):
"""Safely diffs the CloudFormation parameters.
Does a comparison of the locally supplied parameters, and the remotely
discovered (already set) CloudFormation parameters. When they are
different, shows a clean diff and returns False.
Takes into account NoEcho parameters which cannot be diff'd, so should
not be included in the output (likely because they are passwords).
Args:
Remote: A list of objects, each having a ParameterKey and
ParameterValue.
Local: A list of objects, each having a ParameterKey and
ParameterValue.
Returns:
Boolean
"""
# If there are any NoEcho parameters, we can't diff them .. Amazon
# returns them as *****'s and we're unable to compare them. Also, we
# wouldn't want to print these out in our logs because they're almost
# certainly passwords. Therefore, we should simply skip them in the
# diff.
for p in self._noecho_params:
self.log.debug(f'Removing "{p}" from parameters before comparison.')
remote = [pair for pair in remote if pair["ParameterKey"] != p]
local = [pair for pair in local if pair["ParameterKey"] != p]
# Remove any resolved parameter values that were inserted by SSM so that
# only supplied parameter values are compared.
filtered_remote = []
for param in remote:
filtered_param = {}
for k, v in param.items():
if k != "ResolvedValue":
filtered_param[k] = v
filtered_remote.append(filtered_param)
remote = filtered_remote
diff = utils.diff_dicts(remote, local)
if diff:
self.log.warning("Stack parameters do not match.")
for line in diff.split("\n"):
self.log.info(f"Diff: {line}")
return True
return False
def _template_body_with_hash(self) -> str:
"""Add a hash to the template to force a change in the stack."""
# Bail if the user has disabled this feature.
if not KINGPIN_CFN_HASH_OUTPUT_KEY:
return self._template_body
template_obj = json.loads(self._template_body)
if not isinstance(template_obj.get("Outputs", None), dict):
# overwrite the outputs with an empty dict
template_obj["Outputs"] = {}
template_obj["Outputs"][KINGPIN_CFN_HASH_OUTPUT_KEY] = {
"Value": md5(json.dumps(template_obj).encode()).hexdigest()
}
return json.dumps(template_obj)
@gen.coroutine
def _create_change_set(self, stack, uuid=uuid.uuid4().hex):
"""Generates a Change Set.
Takes the current settings (template, capabilities, etc) and generates
a Change Set against the live running stack. Returns back a Change Set
Request dict, which can be used to poll for a real change set.
Args:
stack: Boto3 Stack dict
Returns:
Boto3 Change Set Request dict
"""
change_opts = {
"StackName": stack["StackId"],
"Capabilities": self.option("capabilities"),
"ChangeSetName": f"kingpin-{uuid}",
"Parameters": self._parameters,
"UsePreviousTemplate": False,
}
if self.option("role_arn"):
change_opts["RoleARN"] = self.option("role_arn")
elif KINGPIN_CFN_DEFAULT_ROLE_ARN:
change_opts["RoleARN"] = KINGPIN_CFN_DEFAULT_ROLE_ARN
if self._template_url:
change_opts["TemplateURL"] = self._template_url
else:
change_opts["TemplateBody"] = self._template_body_with_hash()
self.log.info("Generating a stack Change Set...")
try:
change_set_req = yield self.api_call(
self.cfn_conn.create_change_set, **change_opts
)
except ClientError as e:
raise CloudFormationError(e) from e
raise gen.Return(change_set_req)
@gen.coroutine
def _wait_until_change_set_ready(
self, change_set_name, status_key, desired_state, sleep=5
):
"""Waits until a Change Set has hit the desired state.
This loop waits until a Change Set has reached a desired state by
comparing the value of the `status_key` with the `desired_state`. This
allows the method to be used to check the status of the Change Set
generation itself (status_key=Status) as well as the execution of the
Change Set (status_key=ExecutionStatus).
Args:
change_set_name: The Change Set Request Name
status_key: The key within the Change Set that defines its status
desired_state: A string of the desired state we're looking for
sleep: Time to wait between checks in seconds
Returns:
The final completed change set dictionary
"""
self.log.info(f"Waiting for {change_set_name} to reach {desired_state}")
while True:
try:
change = yield self.api_call(
self.cfn_conn.describe_change_set, ChangeSetName=change_set_name
)
except ClientError as e:
# If we hit an intermittent error, lets just loop around and try
# again.
self.log.error(f"Error receiving Change Set state: {e}")
yield utils.tornado_sleep(sleep)
continue
# The Stack State can be 'AVAILABLE', or an IN_PROGRESS string. In
# either case, we loop and wait.
if change[status_key] in (("AVAILABLE",) + IN_PROGRESS):
self.log.info(
f"Change Set state is {change[status_key]}, waiting {sleep}(s)..."
)
yield utils.tornado_sleep(sleep)
continue
# If the stack is in the desired state, then return
if change[status_key] == desired_state:
self.log.debug(
f"Change Set reached desired state: {change[status_key]}"
)
raise gen.Return(change)
# Lastly, if we get here, then something is very wrong and we got
# some funky status back. Throw an exception.
msg = (
f"Unexpected Change Set state ({status_key}) received ({change[status_key]}): "
f"{change.get('StatusReason', 'StatusReason not provided.')}"
)
raise StackFailed(msg)
def _print_change_set(self, change_set):
"""Logs out the changes a Change Set would make if executed.
http://docs.aws.amazon.com/AWSCloudFormation/latest/APIReference/API_DescribeChangeSet.html
Args:
change_set: Change Set Object
"""
self.log.debug(f"Parsing change set: {change_set}")
# Reverse the list, and iterate through the data
for change in change_set["Changes"]:
resource = change["ResourceChange"]
if "PhysicalResourceId" not in resource:
resource["PhysicalResourceId"] = "N/A"
if "Replacement" not in resource:
resource["Replacement"] = False
log_string_fmt = (
"Change: "
"{Action} {ResourceType} "
"{LogicalResourceId}/{PhysicalResourceId} "
"(Replacement? {Replacement})"
)
msg = log_string_fmt.format(**resource)
self.log.warning(msg)
@gen.coroutine
@dry("Would have executed Change Set {change_set_name}")
def _execute_change_set(self, change_set_name):
"""Executes the Change Set and waits for completion.
Takes a supplied Change Set name and Stack Name, executes the change
set, and waits for it to complete sucessfully.
Args:
change_set_name: The Change Set Name/ARN
"""
self.log.info(f"Executing change set {change_set_name}")
try:
yield self.api_call(
self.cfn_conn.execute_change_set, ChangeSetName=change_set_name
)
except ClientError as e:
raise StackFailed(e) from e
change_set = yield self._wait_until_change_set_ready(
change_set_name, "ExecutionStatus", "EXECUTE_COMPLETE"
)
yield self._wait_until_state(
change_set["StackId"], (COMPLETE + FAILED + DELETED)
)
@gen.coroutine
def _ensure_termination_protection(self, stack):
"""Ensures that the EnableTerminationProtection is set to the desired
setting (either True or False).
Checks to to see if the actor is managing EnableTerminationProtection,
and if it is, it updates EnableTerminationProtection if the defined value
is different from the existing one.
Args:
stack: Boto3 Stack dict
"""
existing = stack["EnableTerminationProtection"]
new = self.option("enable_termination_protection")
if new == "UNCHANGED" or existing == new:
raise gen.Return()
yield self._update_termination_protection(stack, new)
@gen.coroutine
@dry("Would have updated EnableTerminationProtection")
def _update_termination_protection(self, stack, new):
"""Updates the EnableTerminationProtection to the new setting.
Args:
stack: Boto3 Stack dict
new: boolean of updated value for EnableTerminationProtection
"""
self.log.info(f"Updating EnableTerminationProtection to {str(new)}")
try:
yield self.api_call(
self.cfn_conn.update_termination_protection,
StackName=stack["StackName"],
EnableTerminationProtection=new,
)
except ClientError as e:
raise StackFailed(e) from e
@gen.coroutine
def _ensure_stack(self):
state = self.option("state")
stack_name = self.option("name")
self.log.info(f"Ensuring that CFN Stack {stack_name} is {state}")
# Figure out if the stack already exists or not. In this case, we
# ignore DELETED stacks because they don't apply or block you from
# creating a new stack.
stack = yield self._get_stack(stack_name)
# Before we figure out what to do, lets make sure the stack isn't in a
# mutating state.
if stack:
yield self._wait_until_state(
stack["StackId"], (COMPLETE + FAILED + DELETED)
)
# Determine the current state of the stack vs the desired state
if state == "absent" and stack is None:
self.log.debug("Stack does not exist")
elif state == "absent" and stack:
yield self._delete_stack(stack=stack_name)
elif state == "present" and stack is None:
stack = yield self._create_stack(stack=stack_name)
elif state == "present" and stack:
stack = yield self._update_stack(stack)
raise gen.Return(stack)
@gen.coroutine
def _execute(self):
# Before we do anything, validate that the supplied template body or
# url is valid. If its not, an exception is raised.
yield self._validate_template(self._template_body, self._template_url)
# This main method triggers the creation, deletion or update of the
# stack as necessary.
yield self._ensure_stack()