Coverage for cfnStack/stack.py: 62%
239 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-17 23:03 +0000
« prev ^ index » next coverage.py v7.11.0, created at 2025-10-17 23:03 +0000
1#!/usr/bin/env python3
3import logging
4import time
5import datetime
6import string
8import boto3
9from botocore.exceptions import ClientError
12class ProcessStack:
13 _timeout = 180
14 _region = "us-west-2"
15 _valid_capabilities = ["CAPABILITY_IAM", "CAPABILITY_NAMED_IAM", "CAPABILITY_AUTO_EXPAND"]
17 def __init__(self, stack_config, confirm=False,
18 timeout=_timeout, region=_region,
19 **kwargs):
21 self.logger = logging.getLogger("ProcessStack")
22 self.kwargs = kwargs
24 self.stack_name = stack_config["stack"]
25 self.stack_cfg = stack_config["stack_cfg"]
26 self.stack_config_json = stack_config["stack_config_json"]
27 self.region = stack_config.get("region", region)
28 self.aws_profile = stack_config.get("profile", "default")
29 self.delete = stack_config.get("delete", False)
30 if self.aws_profile == "default":
31 # To use the default profile set to None
32 self.aws_profile = None
34 self.confirm = confirm
35 self.timeout = timeout
37 self.go = True
39 self.return_status = dict(stack=self.stack_name,
40 profile=self.aws_profile,
41 aws="Unknown", stack_valid="Unknown",
42 changes="Unknown", action="Nothing", fail=False)
44 if self.stack_cfg.get("dynamic_name", False) is not False and isinstance(self.stack_cfg.get("dynamic_name", False), str):
46 dynamic_okay = True
47 # Expand Stack Name With Parameterized Data
49 self.logger.debug("Live Add Things: {}".format(self.kwargs.get("live_add", {})))
51 template_objs = {**self.kwargs.get("live_add", {}).get("parameters", {}),
52 **{x["ParameterKey"]: x["ParameterValue"] for x in
53 self.stack_cfg.get("parameters", list())},
54 **{x["Key"]: x["Value"] for x in self.stack_cfg.get("tags", list())}
55 }
57 template = string.Template(self.stack_cfg["dynamic_name"])
59 try:
60 self.stack_name = template.substitute(template_objs)
61 except Exception as TemplateError:
62 self.logger.error("Error doing Dynamic Name Substitution : {}".format(TemplateError))
63 self.logger.info("Template : {}".format(self.stack_cfg["dynamic_name"]))
64 self.logger.debug("Available Replacements : {}".format(template_objs))
66 self.go = False
67 dynamic_okay = False
68 self.return_status["stack_valid"] = "Dynamic Name Failure"
69 self.return_status["fail"] = True
71 else:
72 self.logger.info("Dynamic Name Went from : {} to {}".format(self.stack_cfg["dynamic_name"], self.stack_name))
73 self.return_status["stack"] = self.stack_name
74 else:
75 # No Dynamic Name Used
76 self.logger.info("No Dynamic Name Used")
77 dynamic_okay = True
79 region_text = str()
80 if self.region != self._region:
81 region_text = "[{}]".format(self.region)
82 self.lname = "{}/{}{}".format(self.stack_name, self.aws_profile, region_text)
84 self.extend_live_add()
86 if self.go:
87 self.cf_client = self.get_client()
89 if self.go:
90 self.clean_change_sets()
92 if self.go:
93 self.validate_stack()
95 if self.go:
96 self.process_changeset()
98 # self.clean_change_sets()
100 def extend_live_add(self):
102 live_add = self.kwargs.get("live_add", {})
104 for key, value in live_add.items():
105 if key == "parameters":
106 for k, v in value.items():
107 this_param = {"ParameterKey": k,
108 "ParameterValue": v}
110 if "parameters" not in self.stack_cfg.keys():
111 self.stack_cfg["parameters"] = list()
113 self.logger.debug("{} Added Live Parameter {} to Stack".format(self.lname, k))
114 self.stack_cfg["parameters"].append(this_param)
116 def clean_change_sets(self):
118 """
119 Clean Up Unused Changesets
120 :return:
121 """
123 if self.stack_exists() == "UPDATE":
124 self.logger.info("{} Cleaning Up Any Outstanding Changestes before beginning.".format(self.lname))
126 changesets = self.cf_client.list_change_sets(StackName=self.stack_name)
128 outstanding_changesets = len(changesets["Summaries"])
130 if outstanding_changesets > 0:
131 for this_changeset in changesets["Summaries"]:
132 self.logger.info(
133 "{} Cleaning Changeset named : {}".format(self.lname, this_changeset["ChangeSetName"]))
135 try:
136 self.cf_client.delete_change_set(ChangeSetName=this_changeset["ChangeSetId"])
137 except Exception as delete_error:
138 self.logger.warning("{} Unable to Delete old Chnageset {}. Hanging Changeset".format(self.lname,
139 this_changeset[
140 "ChangeSetName"]))
141 self.logger.debug("Error: {}".format(delete_error))
142 self.logger.warning("{} Continuing with Hanging Changeset, Clean Manually".format(self.lname))
143 self.go = False
144 self.return_status["fail"] = True
145 else:
146 self.logger.info(
147 "{} Successfully Deleted Changeset {}".format(self.lname, this_changeset["ChangeSetName"]))
148 else:
149 self.logger.info("{} No Oustanding Changests to Clean".format(self.lname))
150 else:
151 self.logger.info("{} New Stack, no outstanding changesets to remove.".format(self.lname))
153 def return_table_row(self):
155 """
156 Retturns a Row of Results as an array for the table
157 :return:
158 """
160 table_row = [self.return_status["stack"],
161 self.return_status["profile"],
162 self.region,
163 self.return_status["aws"],
164 self.return_status["stack_valid"],
165 self.return_status["changes"],
166 self.return_status["action"],
167 self.return_status["fail"]]
169 return table_row
171 def get_client(self):
173 """
174 Get Cloud Formation Client
175 :return:
176 """
178 self.logger.info("{} : Connecting to CloudFormation".format(self.lname))
180 # Validate OKAY Region
182 try:
183 aws_session = boto3.session.Session(profile_name=self.aws_profile, region_name=self.region)
184 cf_client = aws_session.client("cloudformation")
185 except Exception as error:
186 self.logger.error(
187 "Unable to Provision a Cloudformation Client in AWS Profile : {}".format(self.aws_profile))
188 self.logger.debug("Error on Session: {}".format(error))
189 self.return_status["aws"] = "Error"
190 self.go = False
191 self.return_status["fail"] = True
192 cf_client = None
193 else:
194 self.return_status["aws"] = self.aws_profile
196 try:
197 regional_sts = aws_session.client("sts")
198 regional_sts.get_caller_identity()
199 except ClientError as region_error:
200 self.logger.warning("Region {} Unavailable At this Time.".format(self.region))
201 self.go = False
202 self.return_status["aws"] = "Region Unavailable"
203 self.return_status["fail"] = False
204 self.return_status["action"] = "Region Ignore"
206 return cf_client
208 def valiedate_capabilities(self):
210 """
211 Validates that the configurations capabilities make sense
212 """
214 cap_okay = True
216 requested_capabilities = self.stack_cfg.get("capabilities", list())
218 invalid = [x for x in requested_capabilities if x not in self._valid_capabilities]
220 if len(invalid) > 0:
221 self.logger.error("Found {} invalid capabilities.".format(len(invalid)))
222 self.logger.debug("Invalid Capabilities : {}".format(", ".join(invalid)))
223 cap_okay = False
225 return cap_okay
227 def validate_stack(self):
229 """
230 Validate Stack Being Okay
231 :return:
232 """
234 # Validate Stack
235 self.logger.info("{} : Validating Stack Template".format(self.lname))
237 try:
238 self.cf_client.validate_template(TemplateBody=self.stack_config_json)
239 except Exception as ValidationError:
240 self.logger.error("Unable to Validate Template for {}.".format(self.lname))
241 self.logger.debug("Error on Validation: {}".format(ValidationError))
242 self.return_status["stack_valid"] = "Invalid"
243 self.return_status["fail"] = True
244 self.go = False
245 else:
247 if self.valiedate_capabilities() is False:
248 self.return_status["stack_valid"] = "Invalid (Capabilities)"
249 self.return_status["fail"] = "yes"
250 self.go = False
251 else:
253 self.return_status["stack_valid"] = "Valid"
255 def stack_exists(self):
257 """
258 See if Stack Is pre-existing
259 :return:
260 """
262 # See if Exists
263 self.logger.info("{} : Checking if Stack Exists".format(self.lname))
265 try:
266 this_stack_info = self.cf_client.describe_stacks(StackName=self.stack_name)
267 cstype = "UPDATE"
268 except Exception:
269 self.logger.info("{} not does not yet exit, requesting create.".format(self.lname))
270 if self.delete is True:
271 cstype = "DELETED"
272 cstype = "CREATE"
273 else:
274 if this_stack_info["Stacks"][0]["StackStatus"] == "REVIEW_IN_PROGRESS":
275 cstype = "CREATE"
276 finally:
277 if self.delete is True and cstype in ("UPDATE", "CREATE"):
278 cstype = "DELETE"
280 self.logger.info("{} exists, requesting {}.".format(self.lname, cstype))
282 return cstype
284 def wait_for_complete(self, csId):
286 """
287 Wait for Complete
288 :return:
289 """
291 max_time = int(time.time()) + self.timeout
292 complete = "timeout"
294 self.logger.info("Waiting for ChangeSet: {}".format(csId))
296 while int(time.time()) < max_time:
298 current_status = self.cf_client.describe_change_set(ChangeSetName=csId)["Status"]
300 self.logger.debug("Current Changeset : {}".format(current_status))
302 if current_status in ("CREATE_PENDING", "CREATE_IN_PROGRESS"):
303 complete = "in_progress"
305 elif current_status in ("CREATE_COMPLETE", "FAILED"):
306 complete = "yes"
307 break
309 elif current_status in ("DELETE_COMPLETE"):
310 complete = "error"
311 break
313 else:
314 raise TypeError("Unknown Status {}".format(current_status))
316 time.sleep(2)
318 return complete
320 def process_changeset(self):
322 """
323 Process Changeset
325 :return:
326 """
328 cstype = self.stack_exists()
330 # General Args
331 general_args = {"StackName": self.stack_name,
332 "TemplateBody": self.stack_config_json,
333 "Parameters": self.stack_cfg.get("parameters", list()),
334 "Capabilities": self.stack_cfg.get("capabilities", list()),
335 "Tags": self.stack_cfg.get("tags", list()),
336 "Description": self.stack_cfg.get("description", "No Description Given")
337 }
339 self.logger.debug("{} general_args: {}".format(self.lname, general_args))
341 changeset_name = datetime.datetime.today().strftime(
342 "{}-{}-%Y-%m-%d-%s".format(self.stack_name, self.aws_profile))
344 self.logger.info("{} Creating Changeset {}".format(self.lname, changeset_name))
346 if self.delete is False:
347 # Make Changeset to Calculate Changes
349 changeset_ident = self.cf_client.create_change_set(ChangeSetName=changeset_name,
350 ChangeSetType=cstype,
351 **general_args)
353 # Wait until Available
354 cs_complete = self.wait_for_complete(changeset_ident["Id"])
355 if cs_complete != "yes":
356 self.logger.error("Unable to create a changeset {}".format(cs_complete))
357 self.return_status["changes"] = "Error Creating ChangeSet"
358 self.return_status["fail"] = True
359 self.go = False
360 return
362 changeset_info = self.cf_client.describe_change_set(ChangeSetName=changeset_ident["Id"])
364 pending_change = len(changeset_info["Changes"])
365 if cstype == "CREATE":
366 pending_change += 1
368 self.logger.debug("{} changes are outstanding.".format(pending_change))
370 self.return_status["changes"] = "{} Changes".format(pending_change)
371 else:
372 # Delete Reequested
373 pending_change = 1
374 self.return_status["changes"] = "1 Delete"
375 if cstype == "DELETED":
376 pending_change = 0
377 self.return_status["changes"] = "None (Del)"
379 if self.confirm is True and pending_change > 0 and self.delete is False:
380 # Do Change
382 cs_complete = self.wait_for_complete(changeset_ident["Id"])
383 if cs_complete != "yes":
384 self.logger.error("Unable to create a changeset {}".format(cs_complete))
385 self.return_status["changes"] = "Error Creating ChangeSet"
386 self.return_status["fail"] = True
387 self.go = False
388 return
390 self.logger.info("{} : Executing {} Changes".format(self.lname, pending_change))
391 self.cf_client.execute_change_set(ChangeSetName=changeset_ident["Id"])
393 max_utime = int(time.time()) + (self.timeout * 2)
395 self.return_status["action"] = "Timed Out On Update"
397 while int(time.time()) < max_utime:
398 changeset_info = self.cf_client.describe_change_set(ChangeSetName=changeset_ident["Id"])
400 execution_status = changeset_info["ExecutionStatus"]
401 if execution_status == "EXECUTE_COMPLETE":
402 self.logger.info("{} : {} Changes Successfull".format(self.lname, pending_change))
403 self.return_status["action"] = "UPDATE SUCCESS"
404 break
406 if execution_status in ("EXECUTE_FAILED", "OBSOLETE", "UNAVAILABLE"):
407 self.logger.error("{} Error when Doing update {}".format(self.lname, execution_status))
408 self.return_status["action"] = "UPDATED FAILED ({})".format(execution_status)
409 self.return_status["fail"] = True
410 break
412 time.sleep(5)
414 elif pending_change > 0 and self.delete is True:
416 if self.confirm is True:
417 self.logger.info("{} : Attempting Delete".format(self.lname))
419 try:
420 self.cf_client.delete_stack(StackName=general_args["StackName"])
421 except Exception as delete_error:
422 self.logger.error("Unable to Delete Stack with Error : {}".format(delete_error))
423 self.return_status["action"] = "Delete Failure"
425 else:
426 self.return_status["action"] = "Deleted"
428 else:
429 self.logger.info("{} : Would have attempted a Delete, but Confirm not On".format(self.lname))
430 self.return_status["action"] = "CONFIRM OFF (DEL)"
432 elif pending_change > 0 and self.confirm is False:
433 self.logger.info("{} : Stack Has {} changes but Confirm not on.".format(self.lname, pending_change))
434 self.return_status["action"] = "CONFIRM OFF"
436 elif pending_change == 0:
437 # No Changes
438 self.logger.info("{} : Stack Unchanged.".format(self.lname))
439 self.return_status["action"] = "No Pending Changes"
441 return