Coverage for cfnStack/stack.py: 62%

239 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-10-17 23:03 +0000

1#!/usr/bin/env python3 

2 

3import logging 

4import time 

5import datetime 

6import string 

7 

8import boto3 

9from botocore.exceptions import ClientError 

10 

11 

12class ProcessStack: 

13 _timeout = 180 

14 _region = "us-west-2" 

15 _valid_capabilities = ["CAPABILITY_IAM", "CAPABILITY_NAMED_IAM", "CAPABILITY_AUTO_EXPAND"] 

16 

17 def __init__(self, stack_config, confirm=False, 

18 timeout=_timeout, region=_region, 

19 **kwargs): 

20 

21 self.logger = logging.getLogger("ProcessStack") 

22 self.kwargs = kwargs 

23 

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 

33 

34 self.confirm = confirm 

35 self.timeout = timeout 

36 

37 self.go = True 

38 

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) 

43 

44 if self.stack_cfg.get("dynamic_name", False) is not False and isinstance(self.stack_cfg.get("dynamic_name", False), str): 

45 

46 dynamic_okay = True 

47 # Expand Stack Name With Parameterized Data 

48 

49 self.logger.debug("Live Add Things: {}".format(self.kwargs.get("live_add", {}))) 

50 

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 } 

56 

57 template = string.Template(self.stack_cfg["dynamic_name"]) 

58 

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)) 

65 

66 self.go = False 

67 dynamic_okay = False 

68 self.return_status["stack_valid"] = "Dynamic Name Failure" 

69 self.return_status["fail"] = True 

70 

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 

78 

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) 

83 

84 self.extend_live_add() 

85 

86 if self.go: 

87 self.cf_client = self.get_client() 

88 

89 if self.go: 

90 self.clean_change_sets() 

91 

92 if self.go: 

93 self.validate_stack() 

94 

95 if self.go: 

96 self.process_changeset() 

97 

98 # self.clean_change_sets() 

99 

100 def extend_live_add(self): 

101 

102 live_add = self.kwargs.get("live_add", {}) 

103 

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} 

109 

110 if "parameters" not in self.stack_cfg.keys(): 

111 self.stack_cfg["parameters"] = list() 

112 

113 self.logger.debug("{} Added Live Parameter {} to Stack".format(self.lname, k)) 

114 self.stack_cfg["parameters"].append(this_param) 

115 

116 def clean_change_sets(self): 

117 

118 """ 

119 Clean Up Unused Changesets 

120 :return: 

121 """ 

122 

123 if self.stack_exists() == "UPDATE": 

124 self.logger.info("{} Cleaning Up Any Outstanding Changestes before beginning.".format(self.lname)) 

125 

126 changesets = self.cf_client.list_change_sets(StackName=self.stack_name) 

127 

128 outstanding_changesets = len(changesets["Summaries"]) 

129 

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"])) 

134 

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)) 

152 

153 def return_table_row(self): 

154 

155 """ 

156 Retturns a Row of Results as an array for the table 

157 :return: 

158 """ 

159 

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"]] 

168 

169 return table_row 

170 

171 def get_client(self): 

172 

173 """ 

174 Get Cloud Formation Client 

175 :return: 

176 """ 

177 

178 self.logger.info("{} : Connecting to CloudFormation".format(self.lname)) 

179 

180 # Validate OKAY Region 

181 

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 

195 

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" 

205 

206 return cf_client 

207 

208 def valiedate_capabilities(self): 

209 

210 """ 

211 Validates that the configurations capabilities make sense 

212 """ 

213 

214 cap_okay = True 

215 

216 requested_capabilities = self.stack_cfg.get("capabilities", list()) 

217 

218 invalid = [x for x in requested_capabilities if x not in self._valid_capabilities] 

219 

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 

224 

225 return cap_okay 

226 

227 def validate_stack(self): 

228 

229 """ 

230 Validate Stack Being Okay 

231 :return: 

232 """ 

233 

234 # Validate Stack 

235 self.logger.info("{} : Validating Stack Template".format(self.lname)) 

236 

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: 

246 

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: 

252 

253 self.return_status["stack_valid"] = "Valid" 

254 

255 def stack_exists(self): 

256 

257 """ 

258 See if Stack Is pre-existing 

259 :return: 

260 """ 

261 

262 # See if Exists 

263 self.logger.info("{} : Checking if Stack Exists".format(self.lname)) 

264 

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" 

279 

280 self.logger.info("{} exists, requesting {}.".format(self.lname, cstype)) 

281 

282 return cstype 

283 

284 def wait_for_complete(self, csId): 

285 

286 """ 

287 Wait for Complete 

288 :return: 

289 """ 

290 

291 max_time = int(time.time()) + self.timeout 

292 complete = "timeout" 

293 

294 self.logger.info("Waiting for ChangeSet: {}".format(csId)) 

295 

296 while int(time.time()) < max_time: 

297 

298 current_status = self.cf_client.describe_change_set(ChangeSetName=csId)["Status"] 

299 

300 self.logger.debug("Current Changeset : {}".format(current_status)) 

301 

302 if current_status in ("CREATE_PENDING", "CREATE_IN_PROGRESS"): 

303 complete = "in_progress" 

304 

305 elif current_status in ("CREATE_COMPLETE", "FAILED"): 

306 complete = "yes" 

307 break 

308 

309 elif current_status in ("DELETE_COMPLETE"): 

310 complete = "error" 

311 break 

312 

313 else: 

314 raise TypeError("Unknown Status {}".format(current_status)) 

315 

316 time.sleep(2) 

317 

318 return complete 

319 

320 def process_changeset(self): 

321 

322 """ 

323 Process Changeset 

324 

325 :return: 

326 """ 

327 

328 cstype = self.stack_exists() 

329 

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 } 

338 

339 self.logger.debug("{} general_args: {}".format(self.lname, general_args)) 

340 

341 changeset_name = datetime.datetime.today().strftime( 

342 "{}-{}-%Y-%m-%d-%s".format(self.stack_name, self.aws_profile)) 

343 

344 self.logger.info("{} Creating Changeset {}".format(self.lname, changeset_name)) 

345 

346 if self.delete is False: 

347 # Make Changeset to Calculate Changes 

348 

349 changeset_ident = self.cf_client.create_change_set(ChangeSetName=changeset_name, 

350 ChangeSetType=cstype, 

351 **general_args) 

352 

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 

361 

362 changeset_info = self.cf_client.describe_change_set(ChangeSetName=changeset_ident["Id"]) 

363 

364 pending_change = len(changeset_info["Changes"]) 

365 if cstype == "CREATE": 

366 pending_change += 1 

367 

368 self.logger.debug("{} changes are outstanding.".format(pending_change)) 

369 

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)" 

378 

379 if self.confirm is True and pending_change > 0 and self.delete is False: 

380 # Do Change 

381 

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 

389 

390 self.logger.info("{} : Executing {} Changes".format(self.lname, pending_change)) 

391 self.cf_client.execute_change_set(ChangeSetName=changeset_ident["Id"]) 

392 

393 max_utime = int(time.time()) + (self.timeout * 2) 

394 

395 self.return_status["action"] = "Timed Out On Update" 

396 

397 while int(time.time()) < max_utime: 

398 changeset_info = self.cf_client.describe_change_set(ChangeSetName=changeset_ident["Id"]) 

399 

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 

405 

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 

411 

412 time.sleep(5) 

413 

414 elif pending_change > 0 and self.delete is True: 

415 

416 if self.confirm is True: 

417 self.logger.info("{} : Attempting Delete".format(self.lname)) 

418 

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" 

424 

425 else: 

426 self.return_status["action"] = "Deleted" 

427 

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)" 

431 

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" 

435 

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" 

440 

441 return