Skip to content

Commit 78b8662

Browse files
AWSAWS
authored andcommitted
Release: v2.5.0
1 parent f9e2921 commit 78b8662

13 files changed

+208
-49
lines changed

VERSION

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
v2.5.0

customizations-for-aws-control-tower.template

Lines changed: 18 additions & 17 deletions
Large diffs are not rendered by default.

deployment/custom-control-tower-initiation.template

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -838,6 +838,7 @@ Resources:
838838
- Effect: Allow
839839
Action:
840840
- cloudformation:DescribeStackSet
841+
- cloudformation:ListStackSets
841842
- cloudformation:ListStackInstances
842843
- cloudformation:ListStackSetOperations
843844
Resource:

pytest.ini

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,4 +4,6 @@ log_cli = true
44
log_level=WARN
55
markers =
66
unit
7+
integration
8+
e2e
79

source/src/cfct/aws/services/cloudformation.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,17 @@
1919
from botocore.exceptions import ClientError
2020
from cfct.utils.retry_decorator import try_except_retry
2121
from cfct.aws.utils.boto3_session import Boto3Session
22+
from cfct.types import StackSetInstanceTypeDef, StackSetRequestTypeDef, ResourcePropertiesTypeDef
2223
import json
2324

25+
from typing import Dict, List, Any
26+
2427

2528
class StackSet(Boto3Session):
29+
DEPLOYED_BY_CFCT_TAG = {"Key": "AWS_Solutions", "Value": "CustomControlTowerStackSet"}
30+
CFCT_STACK_SET_PREFIX = "CustomControlTower-"
31+
DEPLOY_METHOD = "stack_set"
32+
2633
def __init__(self, logger, **kwargs):
2734
self.logger = logger
2835
__service_name = 'cloudformation'
@@ -35,6 +42,7 @@ def __init__(self, logger, **kwargs):
3542
self.max_results_per_page = 20
3643
super().__init__(logger, __service_name, **kwargs)
3744
self.cfn_client = super().get_client()
45+
3846
self.operation_in_progress_except_msg = \
3947
'Caught exception OperationInProgressException' \
4048
' handling the exception...'
@@ -358,6 +366,91 @@ def list_stack_set_operations(self, **kwargs):
358366
self.logger.log_unhandled_exception(e)
359367
raise
360368

369+
def _filter_managed_stack_set_names(self, list_stackset_response: Dict[str, Any]) -> List[str]:
370+
"""
371+
Reduces a list of given stackset summaries to only those considered managed by CfCT
372+
"""
373+
managed_stack_set_names: List[str] = []
374+
for summary in list_stackset_response['Summaries']:
375+
stack_set_name = summary['StackSetName']
376+
try:
377+
response: Dict[str, Any] = self.cfn_client.describe_stack_set(StackSetName=stack_set_name)
378+
except ClientError as error:
379+
if error.response['Error']['Code'] == "StackSetNotFoundException":
380+
continue
381+
raise
382+
383+
if self.is_managed_by_cfct(describe_stackset_response=response):
384+
managed_stack_set_names.append(stack_set_name)
385+
386+
return managed_stack_set_names
387+
388+
def get_managed_stack_set_names(self) -> List[str]:
389+
"""
390+
Discovers all StackSets prefixed with 'CustomControlTower-' and that
391+
have the tag {Key: AWS_Solutions, Value: CustomControlTowerStackSet}
392+
"""
393+
394+
managed_stackset_names: List[str] = []
395+
paginator = self.cfn_client.get_paginator("list_stack_sets")
396+
for page in paginator.paginate(Status="ACTIVE"):
397+
managed_stackset_names.extend(self._filter_managed_stack_set_names(list_stackset_response=page))
398+
return managed_stackset_names
399+
400+
def is_managed_by_cfct(self, describe_stackset_response: Dict[str, Any]) -> bool:
401+
"""
402+
A StackSet is considered managed if it has both the prefix we expect, and the proper tag
403+
"""
404+
405+
has_tag = StackSet.DEPLOYED_BY_CFCT_TAG in describe_stackset_response['StackSet']['Tags']
406+
has_prefix = describe_stackset_response['StackSet']['StackSetName'].startswith(StackSet.CFCT_STACK_SET_PREFIX)
407+
is_active = describe_stackset_response['StackSet']['Status'] == "ACTIVE"
408+
return all((has_prefix, has_tag, is_active))
409+
410+
def get_stack_sets_not_present_in_manifest(self, manifest_stack_sets: List[str]) -> List[str]:
411+
"""
412+
Compares list of stacksets defined in the manifest versus the stacksets in the account
413+
and returns a list of all stackset names to be deleted
414+
"""
415+
416+
# Stack sets defined in the manifest will not have the CFCT_STACK_SET_PREFIX
417+
# To make comparisons simpler
418+
manifest_stack_sets_with_prefix = [f"{StackSet.CFCT_STACK_SET_PREFIX}{name}" for name in manifest_stack_sets]
419+
cfct_deployed_stack_sets = self.get_managed_stack_set_names()
420+
return list(set(cfct_deployed_stack_sets).difference(set(manifest_stack_sets_with_prefix)))
421+
422+
def generate_delete_request(self, stacksets_to_delete: List[str]) -> List[StackSetRequestTypeDef]:
423+
requests: List[StackSetRequestTypeDef] = []
424+
for stackset_name in stacksets_to_delete:
425+
deployed_instances = self._get_stackset_instances(stackset_name=stackset_name)
426+
requests.append(StackSetRequestTypeDef(
427+
RequestType="Delete",
428+
ResourceProperties=ResourcePropertiesTypeDef(
429+
StackSetName=stackset_name,
430+
TemplateURL="DeleteStackSetNoopURL",
431+
Capabilities=json.dumps(["CAPABILITY_NAMED_IAM", "CAPABILITY_AUTO_EXPAND"]),
432+
Parameters={},
433+
AccountList=list({instance['account'] for instance in deployed_instances}),
434+
RegionList=list({instance['region'] for instance in deployed_instances}),
435+
SSMParameters={}
436+
),
437+
SkipUpdateStackSet="yes",
438+
))
439+
return requests
440+
441+
442+
def _get_stackset_instances(self, stackset_name: str) -> List[StackSetInstanceTypeDef]:
443+
instance_regions_and_accounts: List[StackSetInstanceTypeDef] = []
444+
paginator = self.cfn_client.get_paginator("list_stack_instances")
445+
for page in paginator.paginate(StackSetName=stackset_name):
446+
for summary in page['Summaries']:
447+
instance_regions_and_accounts.append(StackSetInstanceTypeDef(
448+
account=summary['Account'],
449+
region=summary['Region'],
450+
))
451+
452+
return instance_regions_and_accounts
453+
361454

362455
class Stacks(Boto3Session):
363456
def __init__(self, logger, region, **kwargs):

source/src/cfct/aws/utils/boto3_session.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ def __init__(self, logger, service_name, **kwargs):
5959
user_agent_extra=user_agent,
6060
retries={
6161
'mode': 'standard',
62-
'max_attempts': 10
62+
'max_attempts': 20
6363
}
6464
)
6565

source/src/cfct/manifest/manifest.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
##############################################################################
1515

1616
import yorm
17-
from yorm.types import String
17+
from yorm.types import String, Boolean
1818
from yorm.types import List, AttributeDictionary
1919

2020

@@ -158,13 +158,15 @@ def __init__(self):
158158

159159
@yorm.attr(region=String)
160160
@yorm.attr(version=String)
161+
@yorm.attr(enable_stack_set_deletion=Boolean)
161162
@yorm.attr(cloudformation_resources=CfnResourcesList)
162163
@yorm.attr(organization_policies=PolicyList)
163164
@yorm.attr(resources=Resources)
164165
@yorm.sync("{self.manifest_file}", auto_create=False)
165166
class Manifest:
166167
def __init__(self, manifest_file):
167168
self.manifest_file = manifest_file
169+
self.enable_stack_set_deletion = False
168170
self.organization_policies = []
169171
self.cloudformation_resources = []
170172
self.resources = []

source/src/cfct/manifest/manifest_parser.py

Lines changed: 22 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
import os
1717
import sys
1818
import json
19+
from typing import List, Dict, Any
1920
from cfct.utils.logger import Logger
2021
from cfct.manifest.manifest import Manifest
2122
from cfct.manifest.stage_to_s3 import StageFile
@@ -160,6 +161,7 @@ class StackSetParser:
160161

161162
def __init__(self):
162163
self.logger = logger
164+
self.stack_set = StackSet(logger)
163165
self.manifest = Manifest(os.environ.get('MANIFEST_FILE_PATH'))
164166
self.manifest_folder = os.environ.get('MANIFEST_FOLDER')
165167

@@ -216,17 +218,30 @@ def parse_stack_set_manifest_v1(self) -> list:
216218
else:
217219
return state_machine_inputs
218220

221+
222+
223+
219224
def parse_stack_set_manifest_v2(self) -> list:
220225

221226
self.logger.info("Parsing Core Resources from {} file"
222227
.format(os.environ.get('MANIFEST_FILE_PATH')))
223228
build = BuildStateMachineInput(self.manifest.region)
224229
org = OrganizationsData()
225230
organizations_data = org.get_organization_details()
226-
state_machine_inputs = []
231+
232+
state_machine_inputs: List[Dict[str, Any]] = []
233+
234+
if self.manifest.enable_stack_set_deletion:
235+
manifest_stacksets: List[str] = []
236+
for resource in self.manifest.resources:
237+
if resource["deploy_method"] == StackSet.DEPLOY_METHOD:
238+
manifest_stacksets.append(resource['name'])
239+
240+
stacksets_to_be_deleted = self.stack_set.get_stack_sets_not_present_in_manifest(manifest_stack_sets=manifest_stacksets)
241+
state_machine_inputs.extend(self.stack_set.generate_delete_request(stacksets_to_delete=stacksets_to_be_deleted))
227242

228243
for resource in self.manifest.resources:
229-
if resource.deploy_method == 'stack_set':
244+
if resource.deploy_method == StackSet.DEPLOY_METHOD:
230245
self.logger.info(f">>>> START : {resource.name} >>>>")
231246
accounts_in_ou = []
232247

@@ -318,11 +333,13 @@ def stack_set_state_machine_input_v1(self, resource, account_list) -> dict:
318333
region_list = [region]
319334

320335
# if parameter file link is provided for the CFN resource
321-
322-
parameters = self._load_params_from_file(resource.parameter_file)
336+
if resource.parameter_file:
337+
parameters = self._load_params_from_file(resource.parameter_file)
338+
else:
339+
parameters = []
323340

324341
sm_params = self.param_handler.update_params(parameters, account_list,
325-
region, False)
342+
region, False)
326343

327344
ssm_parameters = self._create_ssm_input_map(resource.ssm_parameters)
328345

source/src/cfct/manifest/sm_execution_manager.py

Lines changed: 36 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import tempfile
1919
import filecmp
2020
from uuid import uuid4
21+
from botocore.exceptions import ClientError
2122
from cfct.aws.services.s3 import S3
2223
from cfct.aws.services.state_machine import StateMachine
2324
from cfct.aws.services.cloudformation import StackSet
@@ -65,28 +66,31 @@ def run_execution_sequential_mode(self):
6566
updated_sm_input = self.populate_ssm_params(sm_input)
6667
stack_set_name = sm_input.get('ResourceProperties')\
6768
.get('StackSetName', '')
68-
69-
template_matched, parameters_matched = \
70-
self.compare_template_and_params(sm_input, stack_set_name)
71-
72-
self.logger.info("Stack Set Name: {} | "
73-
"Same Template?: {} | "
74-
"Same Parameters?: {}"
75-
.format(stack_set_name,
76-
template_matched,
77-
parameters_matched))
78-
79-
if template_matched and parameters_matched and self.stack_set_exist:
80-
start_execution_flag = self.compare_stack_instances(
81-
sm_input,
82-
stack_set_name
83-
)
84-
# template and parameter does not require update
85-
updated_sm_input.update({'SkipUpdateStackSet': 'yes'})
86-
else:
87-
# the template or parameters needs to be updated
88-
# start SM execution
69+
is_deletion = sm_input.get("RequestType").lower() == "Delete".lower()
70+
if is_deletion:
8971
start_execution_flag = True
72+
else:
73+
template_matched, parameters_matched = \
74+
self.compare_template_and_params(sm_input, stack_set_name)
75+
76+
self.logger.info("Stack Set Name: {} | "
77+
"Same Template?: {} | "
78+
"Same Parameters?: {}"
79+
.format(stack_set_name,
80+
template_matched,
81+
parameters_matched))
82+
83+
if template_matched and parameters_matched and self.stack_set_exist:
84+
start_execution_flag = self.compare_stack_instances(
85+
sm_input,
86+
stack_set_name
87+
)
88+
# template and parameter does not require update
89+
updated_sm_input.update({'SkipUpdateStackSet': 'yes'})
90+
else:
91+
# the template or parameters needs to be updated
92+
# start SM execution
93+
start_execution_flag = True
9094

9195
if start_execution_flag:
9296

@@ -99,8 +103,16 @@ def run_execution_sequential_mode(self):
99103
self.monitor_state_machines_execution_status()
100104
if status == 'FAILED':
101105
return status, failed_execution_list
102-
elif self.enforce_successful_stack_instances:
103-
self.enforce_stack_set_deployment_successful(stack_set_name)
106+
107+
if self.enforce_successful_stack_instances:
108+
try:
109+
self.enforce_stack_set_deployment_successful(stack_set_name)
110+
except ClientError as error:
111+
if (is_deletion and error.response['Error']['Code'] == "StackSetNotFoundException"):
112+
pass
113+
else:
114+
raise error
115+
104116

105117
else:
106118
self.logger.info("State Machine execution completed. "
@@ -362,7 +374,7 @@ def enforce_stack_set_deployment_successful(self, stack_set_name: str) -> None:
362374
list_filters = [{"Name": "DETAILED_STATUS", "Values": status} for status in failed_detailed_statuses]
363375
# Note that we don't paginate because if this API returns any elements, failed instances exist.
364376
for list_filter in list_filters:
365-
response = self.stack_set.list_stack_instances(StackSetName=stack_set_name, Filters=[list_filter])
377+
response = self.stack_set.cfn_client.list_stack_instances(StackSetName=stack_set_name, Filters=[list_filter])
366378
if response.get("Summaries", []):
367379
raise StackSetHasFailedInstances(stack_set_name=stack_set_name, failed_stack_set_instances=response["Summaries"])
368380
return None

source/src/cfct/state_machine_handler.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@
1919
import time
2020
import tempfile
2121
from random import randint
22-
import os
2322
from botocore.exceptions import ClientError
2423
from cfct.aws.services.organizations import Organizations as Org
2524
from cfct.aws.services.scp import ServiceControlPolicy as SCP

0 commit comments

Comments
 (0)