Skip to content

Commit

Permalink
Release: v2.5.0
Browse files Browse the repository at this point in the history
  • Loading branch information
AWS authored and AWS committed Aug 26, 2022
1 parent f9e2921 commit 78b8662
Show file tree
Hide file tree
Showing 13 changed files with 208 additions and 49 deletions.
1 change: 1 addition & 0 deletions VERSION
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
v2.5.0
35 changes: 18 additions & 17 deletions customizations-for-aws-control-tower.template

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions deployment/custom-control-tower-initiation.template
Original file line number Diff line number Diff line change
Expand Up @@ -838,6 +838,7 @@ Resources:
- Effect: Allow
Action:
- cloudformation:DescribeStackSet
- cloudformation:ListStackSets
- cloudformation:ListStackInstances
- cloudformation:ListStackSetOperations
Resource:
Expand Down
2 changes: 2 additions & 0 deletions pytest.ini
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,6 @@ log_cli = true
log_level=WARN
markers =
unit
integration
e2e

93 changes: 93 additions & 0 deletions source/src/cfct/aws/services/cloudformation.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,17 @@
from botocore.exceptions import ClientError
from cfct.utils.retry_decorator import try_except_retry
from cfct.aws.utils.boto3_session import Boto3Session
from cfct.types import StackSetInstanceTypeDef, StackSetRequestTypeDef, ResourcePropertiesTypeDef
import json

from typing import Dict, List, Any


class StackSet(Boto3Session):
DEPLOYED_BY_CFCT_TAG = {"Key": "AWS_Solutions", "Value": "CustomControlTowerStackSet"}
CFCT_STACK_SET_PREFIX = "CustomControlTower-"
DEPLOY_METHOD = "stack_set"

def __init__(self, logger, **kwargs):
self.logger = logger
__service_name = 'cloudformation'
Expand All @@ -35,6 +42,7 @@ def __init__(self, logger, **kwargs):
self.max_results_per_page = 20
super().__init__(logger, __service_name, **kwargs)
self.cfn_client = super().get_client()

self.operation_in_progress_except_msg = \
'Caught exception OperationInProgressException' \
' handling the exception...'
Expand Down Expand Up @@ -358,6 +366,91 @@ def list_stack_set_operations(self, **kwargs):
self.logger.log_unhandled_exception(e)
raise

def _filter_managed_stack_set_names(self, list_stackset_response: Dict[str, Any]) -> List[str]:
"""
Reduces a list of given stackset summaries to only those considered managed by CfCT
"""
managed_stack_set_names: List[str] = []
for summary in list_stackset_response['Summaries']:
stack_set_name = summary['StackSetName']
try:
response: Dict[str, Any] = self.cfn_client.describe_stack_set(StackSetName=stack_set_name)
except ClientError as error:
if error.response['Error']['Code'] == "StackSetNotFoundException":
continue
raise

if self.is_managed_by_cfct(describe_stackset_response=response):
managed_stack_set_names.append(stack_set_name)

return managed_stack_set_names

def get_managed_stack_set_names(self) -> List[str]:
"""
Discovers all StackSets prefixed with 'CustomControlTower-' and that
have the tag {Key: AWS_Solutions, Value: CustomControlTowerStackSet}
"""

managed_stackset_names: List[str] = []
paginator = self.cfn_client.get_paginator("list_stack_sets")
for page in paginator.paginate(Status="ACTIVE"):
managed_stackset_names.extend(self._filter_managed_stack_set_names(list_stackset_response=page))
return managed_stackset_names

def is_managed_by_cfct(self, describe_stackset_response: Dict[str, Any]) -> bool:
"""
A StackSet is considered managed if it has both the prefix we expect, and the proper tag
"""

has_tag = StackSet.DEPLOYED_BY_CFCT_TAG in describe_stackset_response['StackSet']['Tags']
has_prefix = describe_stackset_response['StackSet']['StackSetName'].startswith(StackSet.CFCT_STACK_SET_PREFIX)
is_active = describe_stackset_response['StackSet']['Status'] == "ACTIVE"
return all((has_prefix, has_tag, is_active))

def get_stack_sets_not_present_in_manifest(self, manifest_stack_sets: List[str]) -> List[str]:
"""
Compares list of stacksets defined in the manifest versus the stacksets in the account
and returns a list of all stackset names to be deleted
"""

# Stack sets defined in the manifest will not have the CFCT_STACK_SET_PREFIX
# To make comparisons simpler
manifest_stack_sets_with_prefix = [f"{StackSet.CFCT_STACK_SET_PREFIX}{name}" for name in manifest_stack_sets]
cfct_deployed_stack_sets = self.get_managed_stack_set_names()
return list(set(cfct_deployed_stack_sets).difference(set(manifest_stack_sets_with_prefix)))

def generate_delete_request(self, stacksets_to_delete: List[str]) -> List[StackSetRequestTypeDef]:
requests: List[StackSetRequestTypeDef] = []
for stackset_name in stacksets_to_delete:
deployed_instances = self._get_stackset_instances(stackset_name=stackset_name)
requests.append(StackSetRequestTypeDef(
RequestType="Delete",
ResourceProperties=ResourcePropertiesTypeDef(
StackSetName=stackset_name,
TemplateURL="DeleteStackSetNoopURL",
Capabilities=json.dumps(["CAPABILITY_NAMED_IAM", "CAPABILITY_AUTO_EXPAND"]),
Parameters={},
AccountList=list({instance['account'] for instance in deployed_instances}),
RegionList=list({instance['region'] for instance in deployed_instances}),
SSMParameters={}
),
SkipUpdateStackSet="yes",
))
return requests


def _get_stackset_instances(self, stackset_name: str) -> List[StackSetInstanceTypeDef]:
instance_regions_and_accounts: List[StackSetInstanceTypeDef] = []
paginator = self.cfn_client.get_paginator("list_stack_instances")
for page in paginator.paginate(StackSetName=stackset_name):
for summary in page['Summaries']:
instance_regions_and_accounts.append(StackSetInstanceTypeDef(
account=summary['Account'],
region=summary['Region'],
))

return instance_regions_and_accounts


class Stacks(Boto3Session):
def __init__(self, logger, region, **kwargs):
Expand Down
2 changes: 1 addition & 1 deletion source/src/cfct/aws/utils/boto3_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ def __init__(self, logger, service_name, **kwargs):
user_agent_extra=user_agent,
retries={
'mode': 'standard',
'max_attempts': 10
'max_attempts': 20
}
)

Expand Down
4 changes: 3 additions & 1 deletion source/src/cfct/manifest/manifest.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
##############################################################################

import yorm
from yorm.types import String
from yorm.types import String, Boolean
from yorm.types import List, AttributeDictionary


Expand Down Expand Up @@ -158,13 +158,15 @@ def __init__(self):

@yorm.attr(region=String)
@yorm.attr(version=String)
@yorm.attr(enable_stack_set_deletion=Boolean)
@yorm.attr(cloudformation_resources=CfnResourcesList)
@yorm.attr(organization_policies=PolicyList)
@yorm.attr(resources=Resources)
@yorm.sync("{self.manifest_file}", auto_create=False)
class Manifest:
def __init__(self, manifest_file):
self.manifest_file = manifest_file
self.enable_stack_set_deletion = False
self.organization_policies = []
self.cloudformation_resources = []
self.resources = []
27 changes: 22 additions & 5 deletions source/src/cfct/manifest/manifest_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
import os
import sys
import json
from typing import List, Dict, Any
from cfct.utils.logger import Logger
from cfct.manifest.manifest import Manifest
from cfct.manifest.stage_to_s3 import StageFile
Expand Down Expand Up @@ -160,6 +161,7 @@ class StackSetParser:

def __init__(self):
self.logger = logger
self.stack_set = StackSet(logger)
self.manifest = Manifest(os.environ.get('MANIFEST_FILE_PATH'))
self.manifest_folder = os.environ.get('MANIFEST_FOLDER')

Expand Down Expand Up @@ -216,17 +218,30 @@ def parse_stack_set_manifest_v1(self) -> list:
else:
return state_machine_inputs




def parse_stack_set_manifest_v2(self) -> list:

self.logger.info("Parsing Core Resources from {} file"
.format(os.environ.get('MANIFEST_FILE_PATH')))
build = BuildStateMachineInput(self.manifest.region)
org = OrganizationsData()
organizations_data = org.get_organization_details()
state_machine_inputs = []

state_machine_inputs: List[Dict[str, Any]] = []

if self.manifest.enable_stack_set_deletion:
manifest_stacksets: List[str] = []
for resource in self.manifest.resources:
if resource["deploy_method"] == StackSet.DEPLOY_METHOD:
manifest_stacksets.append(resource['name'])

stacksets_to_be_deleted = self.stack_set.get_stack_sets_not_present_in_manifest(manifest_stack_sets=manifest_stacksets)
state_machine_inputs.extend(self.stack_set.generate_delete_request(stacksets_to_delete=stacksets_to_be_deleted))

for resource in self.manifest.resources:
if resource.deploy_method == 'stack_set':
if resource.deploy_method == StackSet.DEPLOY_METHOD:
self.logger.info(f">>>> START : {resource.name} >>>>")
accounts_in_ou = []

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

# if parameter file link is provided for the CFN resource

parameters = self._load_params_from_file(resource.parameter_file)
if resource.parameter_file:
parameters = self._load_params_from_file(resource.parameter_file)
else:
parameters = []

sm_params = self.param_handler.update_params(parameters, account_list,
region, False)
region, False)

ssm_parameters = self._create_ssm_input_map(resource.ssm_parameters)

Expand Down
60 changes: 36 additions & 24 deletions source/src/cfct/manifest/sm_execution_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import tempfile
import filecmp
from uuid import uuid4
from botocore.exceptions import ClientError
from cfct.aws.services.s3 import S3
from cfct.aws.services.state_machine import StateMachine
from cfct.aws.services.cloudformation import StackSet
Expand Down Expand Up @@ -65,28 +66,31 @@ def run_execution_sequential_mode(self):
updated_sm_input = self.populate_ssm_params(sm_input)
stack_set_name = sm_input.get('ResourceProperties')\
.get('StackSetName', '')

template_matched, parameters_matched = \
self.compare_template_and_params(sm_input, stack_set_name)

self.logger.info("Stack Set Name: {} | "
"Same Template?: {} | "
"Same Parameters?: {}"
.format(stack_set_name,
template_matched,
parameters_matched))

if template_matched and parameters_matched and self.stack_set_exist:
start_execution_flag = self.compare_stack_instances(
sm_input,
stack_set_name
)
# template and parameter does not require update
updated_sm_input.update({'SkipUpdateStackSet': 'yes'})
else:
# the template or parameters needs to be updated
# start SM execution
is_deletion = sm_input.get("RequestType").lower() == "Delete".lower()
if is_deletion:
start_execution_flag = True
else:
template_matched, parameters_matched = \
self.compare_template_and_params(sm_input, stack_set_name)

self.logger.info("Stack Set Name: {} | "
"Same Template?: {} | "
"Same Parameters?: {}"
.format(stack_set_name,
template_matched,
parameters_matched))

if template_matched and parameters_matched and self.stack_set_exist:
start_execution_flag = self.compare_stack_instances(
sm_input,
stack_set_name
)
# template and parameter does not require update
updated_sm_input.update({'SkipUpdateStackSet': 'yes'})
else:
# the template or parameters needs to be updated
# start SM execution
start_execution_flag = True

if start_execution_flag:

Expand All @@ -99,8 +103,16 @@ def run_execution_sequential_mode(self):
self.monitor_state_machines_execution_status()
if status == 'FAILED':
return status, failed_execution_list
elif self.enforce_successful_stack_instances:
self.enforce_stack_set_deployment_successful(stack_set_name)

if self.enforce_successful_stack_instances:
try:
self.enforce_stack_set_deployment_successful(stack_set_name)
except ClientError as error:
if (is_deletion and error.response['Error']['Code'] == "StackSetNotFoundException"):
pass
else:
raise error


else:
self.logger.info("State Machine execution completed. "
Expand Down Expand Up @@ -362,7 +374,7 @@ def enforce_stack_set_deployment_successful(self, stack_set_name: str) -> None:
list_filters = [{"Name": "DETAILED_STATUS", "Values": status} for status in failed_detailed_statuses]
# Note that we don't paginate because if this API returns any elements, failed instances exist.
for list_filter in list_filters:
response = self.stack_set.list_stack_instances(StackSetName=stack_set_name, Filters=[list_filter])
response = self.stack_set.cfn_client.list_stack_instances(StackSetName=stack_set_name, Filters=[list_filter])
if response.get("Summaries", []):
raise StackSetHasFailedInstances(stack_set_name=stack_set_name, failed_stack_set_instances=response["Summaries"])
return None
1 change: 0 additions & 1 deletion source/src/cfct/state_machine_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@
import time
import tempfile
from random import randint
import os
from botocore.exceptions import ClientError
from cfct.aws.services.organizations import Organizations as Org
from cfct.aws.services.scp import ServiceControlPolicy as SCP
Expand Down
27 changes: 27 additions & 0 deletions source/src/cfct/types.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@

from typing import List, Dict, Any, TypedDict, Literal


class ResourcePropertiesTypeDef(TypedDict):
"""
Capabilities is expected to be a json.dumps of CloudFormation capabilities
"""

StackSetName: str
TemplateURL: str
Capabilities: str
Parameters: Dict[str, Any]
AccountList: List[str]
RegionList: List[str]
SSMParameters: Dict[str, Any]


class StackSetRequestTypeDef(TypedDict):
RequestType: Literal["Delete", "Create"]
ResourceProperties: ResourcePropertiesTypeDef
SkipUpdateStackSet: Literal["no", "yes"]


class StackSetInstanceTypeDef(TypedDict):
account: str
region: str
3 changes: 3 additions & 0 deletions source/src/cfct/validation/manifest-v2.schema.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ mapping:
type: date
required: True
enum: [2021-03-15]
"enable_stack_set_deletion":
type: bool
required: False
"resources":
type: seq
sequence:
Expand Down
1 change: 1 addition & 0 deletions source/src/setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
"pytest == 6.2.4",
"mypy == 0.930",
"expecter==0.3.0",
"pykwalify == 1.8.0"
],
"dev": [
"ipython",
Expand Down

0 comments on commit 78b8662

Please sign in to comment.