#!/usr/bin/env python3
import logging
import time
import datetime
import string
import boto3
from botocore.exceptions import ClientError
[docs]
class ProcessStack:
_timeout = 180
_region = "us-west-2"
_valid_capabilities = ["CAPABILITY_IAM", "CAPABILITY_NAMED_IAM", "CAPABILITY_AUTO_EXPAND"]
def __init__(self, stack_config, confirm=False,
timeout=_timeout, region=_region,
**kwargs):
self.logger = logging.getLogger("ProcessStack")
self.kwargs = kwargs
self.stack_name = stack_config["stack"]
self.stack_cfg = stack_config["stack_cfg"]
self.stack_config_json = stack_config["stack_config_json"]
self.region = stack_config.get("region", region)
self.aws_profile = stack_config.get("profile", "default")
self.delete = stack_config.get("delete", False)
if self.aws_profile == "default":
# To use the default profile set to None
self.aws_profile = None
self.confirm = confirm
self.timeout = timeout
self.go = True
self.return_status = dict(stack=self.stack_name,
profile=self.aws_profile,
aws="Unknown", stack_valid="Unknown",
changes="Unknown", action="Nothing", fail=False)
if self.stack_cfg.get("dynamic_name", False) is not False and isinstance(self.stack_cfg.get("dynamic_name", False), str):
dynamic_okay = True
# Expand Stack Name With Parameterized Data
self.logger.debug("Live Add Things: {}".format(self.kwargs.get("live_add", {})))
template_objs = {**self.kwargs.get("live_add", {}).get("parameters", {}),
**{x["ParameterKey"]: x["ParameterValue"] for x in
self.stack_cfg.get("parameters", list())},
**{x["Key"]: x["Value"] for x in self.stack_cfg.get("tags", list())}
}
template = string.Template(self.stack_cfg["dynamic_name"])
try:
self.stack_name = template.substitute(template_objs)
except Exception as TemplateError:
self.logger.error("Error doing Dynamic Name Substitution : {}".format(TemplateError))
self.logger.info("Template : {}".format(self.stack_cfg["dynamic_name"]))
self.logger.debug("Available Replacements : {}".format(template_objs))
self.go = False
dynamic_okay = False
self.return_status["stack_valid"] = "Dynamic Name Failure"
self.return_status["fail"] = True
else:
self.logger.info("Dynamic Name Went from : {} to {}".format(self.stack_cfg["dynamic_name"], self.stack_name))
self.return_status["stack"] = self.stack_name
else:
# No Dynamic Name Used
self.logger.info("No Dynamic Name Used")
dynamic_okay = True
region_text = str()
if self.region != self._region:
region_text = "[{}]".format(self.region)
self.lname = "{}/{}{}".format(self.stack_name, self.aws_profile, region_text)
self.extend_live_add()
if self.go:
self.cf_client = self.get_client()
if self.go:
self.clean_change_sets()
if self.go:
self.validate_stack()
if self.go:
self.process_changeset()
# self.clean_change_sets()
def extend_live_add(self):
live_add = self.kwargs.get("live_add", {})
for key, value in live_add.items():
if key == "parameters":
for k, v in value.items():
this_param = {"ParameterKey": k,
"ParameterValue": v}
if "parameters" not in self.stack_cfg.keys():
self.stack_cfg["parameters"] = list()
self.logger.debug("{} Added Live Parameter {} to Stack".format(self.lname, k))
self.stack_cfg["parameters"].append(this_param)
[docs]
def clean_change_sets(self):
"""
Clean Up Unused Changesets
:return:
"""
if self.stack_exists() == "UPDATE":
self.logger.info("{} Cleaning Up Any Outstanding Changestes before beginning.".format(self.lname))
changesets = self.cf_client.list_change_sets(StackName=self.stack_name)
outstanding_changesets = len(changesets["Summaries"])
if outstanding_changesets > 0:
for this_changeset in changesets["Summaries"]:
self.logger.info(
"{} Cleaning Changeset named : {}".format(self.lname, this_changeset["ChangeSetName"]))
try:
self.cf_client.delete_change_set(ChangeSetName=this_changeset["ChangeSetId"])
except Exception as delete_error:
self.logger.warning("{} Unable to Delete old Chnageset {}. Hanging Changeset".format(self.lname,
this_changeset[
"ChangeSetName"]))
self.logger.debug("Error: {}".format(delete_error))
self.logger.warning("{} Continuing with Hanging Changeset, Clean Manually".format(self.lname))
self.go = False
self.return_status["fail"] = True
else:
self.logger.info(
"{} Successfully Deleted Changeset {}".format(self.lname, this_changeset["ChangeSetName"]))
else:
self.logger.info("{} No Oustanding Changests to Clean".format(self.lname))
else:
self.logger.info("{} New Stack, no outstanding changesets to remove.".format(self.lname))
[docs]
def return_table_row(self):
"""
Retturns a Row of Results as an array for the table
:return:
"""
table_row = [self.return_status["stack"],
self.return_status["profile"],
self.region,
self.return_status["aws"],
self.return_status["stack_valid"],
self.return_status["changes"],
self.return_status["action"],
self.return_status["fail"]]
return table_row
[docs]
def get_client(self):
"""
Get Cloud Formation Client
:return:
"""
self.logger.info("{} : Connecting to CloudFormation".format(self.lname))
# Validate OKAY Region
try:
aws_session = boto3.session.Session(profile_name=self.aws_profile, region_name=self.region)
cf_client = aws_session.client("cloudformation")
except Exception as error:
self.logger.error(
"Unable to Provision a Cloudformation Client in AWS Profile : {}".format(self.aws_profile))
self.logger.debug("Error on Session: {}".format(error))
self.return_status["aws"] = "Error"
self.go = False
self.return_status["fail"] = True
cf_client = None
else:
self.return_status["aws"] = self.aws_profile
try:
regional_sts = aws_session.client("sts")
regional_sts.get_caller_identity()
except ClientError as region_error:
self.logger.warning("Region {} Unavailable At this Time.".format(self.region))
self.go = False
self.return_status["aws"] = "Region Unavailable"
self.return_status["fail"] = False
self.return_status["action"] = "Region Ignore"
return cf_client
[docs]
def valiedate_capabilities(self):
"""
Validates that the configurations capabilities make sense
"""
cap_okay = True
requested_capabilities = self.stack_cfg.get("capabilities", list())
invalid = [x for x in requested_capabilities if x not in self._valid_capabilities]
if len(invalid) > 0:
self.logger.error("Found {} invalid capabilities.".format(len(invalid)))
self.logger.debug("Invalid Capabilities : {}".format(", ".join(invalid)))
cap_okay = False
return cap_okay
[docs]
def validate_stack(self):
"""
Validate Stack Being Okay
:return:
"""
# Validate Stack
self.logger.info("{} : Validating Stack Template".format(self.lname))
try:
self.cf_client.validate_template(TemplateBody=self.stack_config_json)
except Exception as ValidationError:
self.logger.error("Unable to Validate Template for {}.".format(self.lname))
self.logger.debug("Error on Validation: {}".format(ValidationError))
self.return_status["stack_valid"] = "Invalid"
self.return_status["fail"] = True
self.go = False
else:
if self.valiedate_capabilities() is False:
self.return_status["stack_valid"] = "Invalid (Capabilities)"
self.return_status["fail"] = "yes"
self.go = False
else:
self.return_status["stack_valid"] = "Valid"
[docs]
def stack_exists(self):
"""
See if Stack Is pre-existing
:return:
"""
# See if Exists
self.logger.info("{} : Checking if Stack Exists".format(self.lname))
try:
this_stack_info = self.cf_client.describe_stacks(StackName=self.stack_name)
cstype = "UPDATE"
except Exception:
self.logger.info("{} not does not yet exit, requesting create.".format(self.lname))
if self.delete is True:
cstype = "DELETED"
cstype = "CREATE"
else:
if this_stack_info["Stacks"][0]["StackStatus"] == "REVIEW_IN_PROGRESS":
cstype = "CREATE"
finally:
if self.delete is True and cstype in ("UPDATE", "CREATE"):
cstype = "DELETE"
self.logger.info("{} exists, requesting {}.".format(self.lname, cstype))
return cstype
[docs]
def wait_for_complete(self, csId):
"""
Wait for Complete
:return:
"""
max_time = int(time.time()) + self.timeout
complete = "timeout"
self.logger.info("Waiting for ChangeSet: {}".format(csId))
while int(time.time()) < max_time:
current_status = self.cf_client.describe_change_set(ChangeSetName=csId)["Status"]
self.logger.debug("Current Changeset : {}".format(current_status))
if current_status in ("CREATE_PENDING", "CREATE_IN_PROGRESS"):
complete = "in_progress"
elif current_status in ("CREATE_COMPLETE", "FAILED"):
complete = "yes"
break
elif current_status in ("DELETE_COMPLETE"):
complete = "error"
break
else:
raise TypeError("Unknown Status {}".format(current_status))
time.sleep(2)
return complete
[docs]
def process_changeset(self):
"""
Process Changeset
:return:
"""
cstype = self.stack_exists()
# General Args
general_args = {"StackName": self.stack_name,
"TemplateBody": self.stack_config_json,
"Parameters": self.stack_cfg.get("parameters", list()),
"Capabilities": self.stack_cfg.get("capabilities", list()),
"Tags": self.stack_cfg.get("tags", list()),
"Description": self.stack_cfg.get("description", "No Description Given")
}
self.logger.debug("{} general_args: {}".format(self.lname, general_args))
changeset_name = datetime.datetime.today().strftime(
"{}-{}-%Y-%m-%d-%s".format(self.stack_name, self.aws_profile))
self.logger.info("{} Creating Changeset {}".format(self.lname, changeset_name))
if self.delete is False:
# Make Changeset to Calculate Changes
changeset_ident = self.cf_client.create_change_set(ChangeSetName=changeset_name,
ChangeSetType=cstype,
**general_args)
# Wait until Available
cs_complete = self.wait_for_complete(changeset_ident["Id"])
if cs_complete != "yes":
self.logger.error("Unable to create a changeset {}".format(cs_complete))
self.return_status["changes"] = "Error Creating ChangeSet"
self.return_status["fail"] = True
self.go = False
return
changeset_info = self.cf_client.describe_change_set(ChangeSetName=changeset_ident["Id"])
pending_change = len(changeset_info["Changes"])
if cstype == "CREATE":
pending_change += 1
self.logger.debug("{} changes are outstanding.".format(pending_change))
self.return_status["changes"] = "{} Changes".format(pending_change)
else:
# Delete Reequested
pending_change = 1
self.return_status["changes"] = "1 Delete"
if cstype == "DELETED":
pending_change = 0
self.return_status["changes"] = "None (Del)"
if self.confirm is True and pending_change > 0 and self.delete is False:
# Do Change
cs_complete = self.wait_for_complete(changeset_ident["Id"])
if cs_complete != "yes":
self.logger.error("Unable to create a changeset {}".format(cs_complete))
self.return_status["changes"] = "Error Creating ChangeSet"
self.return_status["fail"] = True
self.go = False
return
self.logger.info("{} : Executing {} Changes".format(self.lname, pending_change))
self.cf_client.execute_change_set(ChangeSetName=changeset_ident["Id"])
max_utime = int(time.time()) + (self.timeout * 2)
self.return_status["action"] = "Timed Out On Update"
while int(time.time()) < max_utime:
changeset_info = self.cf_client.describe_change_set(ChangeSetName=changeset_ident["Id"])
execution_status = changeset_info["ExecutionStatus"]
if execution_status == "EXECUTE_COMPLETE":
self.logger.info("{} : {} Changes Successfull".format(self.lname, pending_change))
self.return_status["action"] = "UPDATE SUCCESS"
break
if execution_status in ("EXECUTE_FAILED", "OBSOLETE", "UNAVAILABLE"):
self.logger.error("{} Error when Doing update {}".format(self.lname, execution_status))
self.return_status["action"] = "UPDATED FAILED ({})".format(execution_status)
self.return_status["fail"] = True
break
time.sleep(5)
elif pending_change > 0 and self.delete is True:
if self.confirm is True:
self.logger.info("{} : Attempting Delete".format(self.lname))
try:
self.cf_client.delete_stack(StackName=general_args["StackName"])
except Exception as delete_error:
self.logger.error("Unable to Delete Stack with Error : {}".format(delete_error))
self.return_status["action"] = "Delete Failure"
else:
self.return_status["action"] = "Deleted"
else:
self.logger.info("{} : Would have attempted a Delete, but Confirm not On".format(self.lname))
self.return_status["action"] = "CONFIRM OFF (DEL)"
elif pending_change > 0 and self.confirm is False:
self.logger.info("{} : Stack Has {} changes but Confirm not on.".format(self.lname, pending_change))
self.return_status["action"] = "CONFIRM OFF"
elif pending_change == 0:
# No Changes
self.logger.info("{} : Stack Unchanged.".format(self.lname))
self.return_status["action"] = "No Pending Changes"
return