From 5bd3d36a809b2f2dab180bddd212db809b650826 Mon Sep 17 00:00:00 2001 From: Jason Brown Date: Tue, 28 Apr 2020 16:05:17 -0400 Subject: [PATCH 01/36] -Updated Config class constructor arguments and parameters --- deployer/configuration.py | 83 ++++++++++++++++++++++++++++++++++----- 1 file changed, 73 insertions(+), 10 deletions(-) diff --git a/deployer/configuration.py b/deployer/configuration.py index 73c633a..9927ec3 100644 --- a/deployer/configuration.py +++ b/deployer/configuration.py @@ -2,12 +2,80 @@ from deployer.logger import logger from deployer.stack import Stack import ruamel.yaml, json, re +from collections import MutableMapping class Config(object): - def __init__(self, file_name, master_stack): - self.file_name = file_name - self.config = self.get_config() + def __init__(self, master_stack, profile, file_name=None): + self.config = self._get_config(file_name) + self.profile = profile self.stack = master_stack + + self.region = "us-east-1" + self.table_name = "CFN-Deployer" + + def _get_config(self, file_name=None): + + #Create session + try: + session = Session(profile_name=self.profile, region_name=self.region) + dynamo = session.client('dynamo') + + #Check for Dynamo state table + resp_tables = dynamo.list_tables() + if not self.table_name in resp_tables['TableNames']: + #Since it doesn't exist, create it + self._create_state_table(dynamo) + + #Retrieve data from table and format it + scan_resp = dynamo.scan(TableName=self.table_name) + + data = {} + for item in scan_resp['Items']: + #Each item represents a stack with map (M) type + for stackname in item: + values = item[stackname] + data[stackname] = values['M'] + + except Exception as e: + msg = str(e) + logger.error("Failed to retrieve data from dynamo state table {}: {}".format(self.table_name,msg)) + exit(3) + + #Check for file_name + if file_name: + try: + with open(file_name) as f: + file_data = ruamel.yaml.safe_load(f) + except Exception as e: + msg = str(e) + logger.error("Failed to retrieve data from config file {}: {}".format(file_name,msg)) + exit(3) + + #Compare data from state table and file, update state table data with file data if different + finalstate = self._dict_merge(data, file_data) + data = finalstate + + #Update Dynamo table if necessary + + + return data + + def _create_state_table(self, dynamo): + #Create Dynamo DB state table + + return + + def _dict_merge(self, old, new): + #Recursively go through the nested dictionaries, with values in + # 'new' overwriting the values in 'old' for the same key + + for k, v in old.items(): + if k in new: + if all(isinstance(e, MutableMapping) for e in (v, new[k])): + new[k] = self._dict_merge(v, new[k]) + merged = old.copy() + merged.update(new) + return merged def build_params(self, session, stack_name, release, params, temp_file): # create parameters from the config.yml file @@ -67,15 +135,10 @@ def build_params(self, session, stack_name, release, params, temp_file): logger.info("Parameters Created") return return_params - def get_config(self): - with open(self.file_name) as f: - data = ruamel.yaml.safe_load(f) - return data - def get_config_att(self, key, default=None, required=False): base = self.config.get('global', {}).get(key, None) base = self.config.get(self.stack).get(key, base) if required and base is None: - logger.error("Required attribute '{}' not found in config '{}'.".format(key, self.file_name)) + logger.error("Required attribute '{}' not found in config.".format(key)) exit(3) - return base if base is not None else default \ No newline at end of file + return base if base is not None else default From e65f77c135f7d41861a095c6a0312405c2487a0a Mon Sep 17 00:00:00 2001 From: Jason Brown Date: Wed, 29 Apr 2020 12:28:16 -0400 Subject: [PATCH 02/36] Updated Config class to create the state table if necessary, and update the table with new data --- deployer/configuration.py | 73 +++++++++++++++++++++++++++++++++++---- 1 file changed, 67 insertions(+), 6 deletions(-) diff --git a/deployer/configuration.py b/deployer/configuration.py index 9927ec3..b2127c4 100644 --- a/deployer/configuration.py +++ b/deployer/configuration.py @@ -31,10 +31,10 @@ def _get_config(self, file_name=None): data = {} for item in scan_resp['Items']: - #Each item represents a stack with map (M) type - for stackname in item: - values = item[stackname] - data[stackname] = values['M'] + #Each item represents a stack + stackname = item['stackname']['S'] + stackconfig = item['stackconfig']['M'] + data[stackname] = stackconfig except Exception as e: msg = str(e) @@ -55,16 +55,77 @@ def _get_config(self, file_name=None): finalstate = self._dict_merge(data, file_data) data = finalstate - #Update Dynamo table if necessary - + #Update Dynamo table + self._update_state_table(dynamo, data) return data def _create_state_table(self, dynamo): + + #Set up the arguments + kwargs = { + 'AttributeDefinitions':[ + { + 'AttributeName': 'stackname', + 'AttributeType': 'S' + }, + { + 'AttributeName': 'stackconfig', + 'AttributeType': 'M' + }, + ], + 'TableName': self.table_name, + 'KeySchema':[ + { + 'AttributeName': 'stackname', + 'KeyType': 'HASH' + }, + ] + } + #Create Dynamo DB state table + try: + response = dynamo.create_table(**kwargs) + except Exception as e: + msg = str(e) + logger.error("Failed to retrieve data from dynamo state table {}: {}".format(self.table_name,msg)) + exit(3) + + return + + def _update_state_table(self, dynamo, data): + + #Loop over stacks + for stackname in data.keys(): + stackconfig = data[stackname] + + #Set up the arguments + kwargs = { + "TableName": self.table_name, + "Key": { + "stackname": { + "S": stackname + } + }, + "UpdateExpression": "set stackconfig = :val", + "ExpressionAttributeValues": { + ":val": {"M": stackconfig} + } + } + + try: + response = dynamo.update_item(**kwargs) + except Exception as e: + msg = str(e) + logger.error("Failed to update data to dynamo state table {}: {}".format(self.table_name,msg)) + exit(3) return + def list_stacks(self): + #This includes global settings as a stack + return self.config.keys() + def _dict_merge(self, old, new): #Recursively go through the nested dictionaries, with values in # 'new' overwriting the values in 'old' for the same key From 82d09acacd87b6a817bc9f5f59d580d67086cd57 Mon Sep 17 00:00:00 2001 From: Jason Brown Date: Wed, 29 Apr 2020 15:47:00 -0400 Subject: [PATCH 03/36] Adjusted __init__ to use updated Config class --- deployer/__init__.py | 21 +++++++++------------ deployer/configuration.py | 10 ++++++++-- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/deployer/__init__.py b/deployer/__init__.py index e22154c..d857159 100755 --- a/deployer/__init__.py +++ b/deployer/__init__.py @@ -24,7 +24,7 @@ def main(): # Build arguement parser parser = argparse.ArgumentParser(description='Deploy CloudFormation Templates') - parser.add_argument("-c", "--config", help="Path to config file.") + parser.add_argument("-c", "--config", help="Path to config file.",default=None) parser.add_argument("-s", "--stack", help="Stack Name.") parser.add_argument("-x", "--execute", help="Execute ( create | update | delete | upsert | sync | change ) of stack.") parser.add_argument("-P", "--param", action='append', help='An override for a parameter') @@ -71,8 +71,6 @@ def main(): # Validate arguements and parameters options_broken = False params = {} - if not args.config: - args.config = 'config.yml' if not args.all: if not args.execute: print(colors['warning'] + "Must Specify execute flag!" + colors['reset']) @@ -100,33 +98,32 @@ def main(): console_logger.setLevel(logging.ERROR) try: - # Read Environment Config - with open(args.config) as f: - config = ruamel.yaml.safe_load(f) + # Create deployer config object + config_object = Config(args.profile, args.config) # Load stacks into queue stackQueue = [] if not args.all: stackQueue = [args.stack] else: - for stack in config.items(): + for stack in config_object.list_stacks(): if stack[0] != "global": - stackQueue = find_deploy_path(config, stack[0], stackQueue) + stackQueue = find_deploy_path(config_object.get_config(), stack[0], stackQueue) # Create or update all Environments for stack in stackQueue: if stack != 'global' and (args.all or stack == args.stack): logger.info("Running " + colors['underline'] + str(args.execute) + colors['reset'] + " on stack: " + colors['stack'] + stack + colors['reset']) + + #Setting the stack context for config object + config_object.set_master_stack(stack) # Build lambdas on `-z` if args.zip_lambdas: logger.info("Building lambdas for stack: " + stack) LambdaPrep(args.config, args.stack).zip_lambdas() - - # Create deployer config object - config_object = Config(args.config, stack) - + # AWS Session object session = Session(profile_name=args.profile, region_name=config_object.get_config_att('region')) diff --git a/deployer/configuration.py b/deployer/configuration.py index b2127c4..497c580 100644 --- a/deployer/configuration.py +++ b/deployer/configuration.py @@ -5,10 +5,9 @@ from collections import MutableMapping class Config(object): - def __init__(self, master_stack, profile, file_name=None): + def __init__(self, profile, file_name=None): self.config = self._get_config(file_name) self.profile = profile - self.stack = master_stack self.region = "us-east-1" self.table_name = "CFN-Deployer" @@ -203,3 +202,10 @@ def get_config_att(self, key, default=None, required=False): logger.error("Required attribute '{}' not found in config.".format(key)) exit(3) return base if base is not None else default + + def get_config(self): + return self.config + + def set_master_stack(self, master_stack): + self.stack = master_stack + return From 10bb860a685b56290a2bb77159d8640f4d9b596f Mon Sep 17 00:00:00 2001 From: Jason Brown Date: Thu, 30 Apr 2020 12:06:22 -0400 Subject: [PATCH 04/36] Fixed issue with table creation and checking existence of table --- deployer/configuration.py | 61 ++++++++++++++++++++++++++------------- 1 file changed, 41 insertions(+), 20 deletions(-) diff --git a/deployer/configuration.py b/deployer/configuration.py index 497c580..9dc3086 100644 --- a/deployer/configuration.py +++ b/deployer/configuration.py @@ -3,30 +3,33 @@ from deployer.stack import Stack import ruamel.yaml, json, re from collections import MutableMapping +from time import sleep +from boto3.session import Session class Config(object): def __init__(self, profile, file_name=None): - self.config = self._get_config(file_name) - self.profile = profile - self.region = "us-east-1" self.table_name = "CFN-Deployer" + self.profile = profile + + #Create boto3 session and dynamo client + self.session = Session(profile_name=self.profile, region_name=self.region) + self.dynamo = self.session.client('dynamodb') + + self.config = self._get_config(file_name) def _get_config(self, file_name=None): #Create session try: - session = Session(profile_name=self.profile, region_name=self.region) - dynamo = session.client('dynamo') #Check for Dynamo state table - resp_tables = dynamo.list_tables() - if not self.table_name in resp_tables['TableNames']: + if not self._table_exists(): #Since it doesn't exist, create it - self._create_state_table(dynamo) + self._create_state_table() #Retrieve data from table and format it - scan_resp = dynamo.scan(TableName=self.table_name) + scan_resp = self.dynamo.scan(TableName=self.table_name) data = {} for item in scan_resp['Items']: @@ -55,11 +58,19 @@ def _get_config(self, file_name=None): data = finalstate #Update Dynamo table - self._update_state_table(dynamo, data) + self._update_state_table(data) return data - def _create_state_table(self, dynamo): + def _table_exists(self): + resp_tables = self.dynamo.list_tables() + if self.table_name in resp_tables['TableNames']: + resp_table = self.dynamo.describe_table(TableName=self.table_name) + if resp_table['Table']['TableStatus'] == 'ACTIVE': + return True + return False + + def _create_state_table(self): #Set up the arguments kwargs = { @@ -67,11 +78,7 @@ def _create_state_table(self, dynamo): { 'AttributeName': 'stackname', 'AttributeType': 'S' - }, - { - 'AttributeName': 'stackconfig', - 'AttributeType': 'M' - }, + } ], 'TableName': self.table_name, 'KeySchema':[ @@ -79,12 +86,26 @@ def _create_state_table(self, dynamo): 'AttributeName': 'stackname', 'KeyType': 'HASH' }, - ] + ], + 'BillingMode': 'PAY_PER_REQUEST' } #Create Dynamo DB state table try: - response = dynamo.create_table(**kwargs) + logger.info("Attempting to create table") + response = self.dynamo.create_table(**kwargs) + + #Waiting for the table to exist + counter = 0 + limit = 10 + while counter < limit: + sleep(1) + if self._table_exists(): + return + counter+=1 + + raise Exception("Timeout occurred while waiting for Dynamo table creation") + except Exception as e: msg = str(e) logger.error("Failed to retrieve data from dynamo state table {}: {}".format(self.table_name,msg)) @@ -92,7 +113,7 @@ def _create_state_table(self, dynamo): return - def _update_state_table(self, dynamo, data): + def _update_state_table(self, data): #Loop over stacks for stackname in data.keys(): @@ -113,7 +134,7 @@ def _update_state_table(self, dynamo, data): } try: - response = dynamo.update_item(**kwargs) + response = self.dynamo.update_item(**kwargs) except Exception as e: msg = str(e) logger.error("Failed to update data to dynamo state table {}: {}".format(self.table_name,msg)) From c6ded8cddae0076ca4349c4f8df5c0a875f924ab Mon Sep 17 00:00:00 2001 From: Jason Brown Date: Thu, 30 Apr 2020 13:26:21 -0400 Subject: [PATCH 05/36] Fixed format for updating items --- deployer/configuration.py | 24 ++++++++++++++++++++++-- deployer/stack.py | 4 ++-- deployer/stack_sets.py | 4 ++-- 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/deployer/configuration.py b/deployer/configuration.py index 9dc3086..52fcb72 100644 --- a/deployer/configuration.py +++ b/deployer/configuration.py @@ -11,6 +11,7 @@ def __init__(self, profile, file_name=None): self.region = "us-east-1" self.table_name = "CFN-Deployer" self.profile = profile + self.file_name = file_name #Create boto3 session and dynamo client self.session = Session(profile_name=self.profile, region_name=self.region) @@ -117,7 +118,10 @@ def _update_state_table(self, data): #Loop over stacks for stackname in data.keys(): - stackconfig = data[stackname] + logger.info("Updating stack: {}".format(stackname)) + #stackconfig = data[stackname] + stackconfig = self._recursive_dynamo_conversion(data[stackname]) + logger.info(stackconfig) #Set up the arguments kwargs = { @@ -129,7 +133,8 @@ def _update_state_table(self, data): }, "UpdateExpression": "set stackconfig = :val", "ExpressionAttributeValues": { - ":val": {"M": stackconfig} + #":val": {"M": stackconfig} + ":val": stackconfig } } @@ -142,6 +147,21 @@ def _update_state_table(self, data): return + def _recursive_dynamo_conversion(self, param): + + if isinstance(param, dict): + paramdict = {} + for key in param.keys(): + paramdict[key] = self._recursive_dynamo_conversion(param[key]) + return {'M': paramdict} + elif isinstance(param, list): + #paramlist = self._recursive_dynamo_conversion(item) for item in param + return {'L': [ self._recursive_dynamo_conversion(item) for item in param ] } + + #For everything else, force it to be a string type for Dynamo + + return {'S': param} + def list_stacks(self): #This includes global settings as a stack return self.config.keys() diff --git a/deployer/stack.py b/deployer/stack.py index f9b9b30..3d051be 100644 --- a/deployer/stack.py +++ b/deployer/stack.py @@ -83,7 +83,7 @@ def construct_tags(self): tags.append({'Key': 'deployer:caller', 'Value': self.identity_arn}) tags.append({'Key': 'deployer:git:commit', 'Value': self.commit}) tags.append({'Key': 'deployer:git:origin', 'Value': self.origin}) - tags.append({'Key': 'deployer:config', 'Value': self.config.file_name.replace('\\', '/')}) + #tags.append({'Key': 'deployer:config', 'Value': self.config.file_name.replace('\\', '/')}) return tags @@ -367,4 +367,4 @@ def reload_stack_status(self): self.stack_status = resp['Stacks'][0]['StackStatus'] except Exception: self.stack_status = 'False' - return self.stack_status \ No newline at end of file + return self.stack_status diff --git a/deployer/stack_sets.py b/deployer/stack_sets.py index 665c348..00c0aa1 100644 --- a/deployer/stack_sets.py +++ b/deployer/stack_sets.py @@ -304,5 +304,5 @@ def construct_tags(self): tags.append({'Key': 'deployer:caller', 'Value': self.identity_arn}) tags.append({'Key': 'deployer:git:commit', 'Value': self.commit}) tags.append({'Key': 'deployer:git:origin', 'Value': self.origin}) - tags.append({'Key': 'deployer:config', 'Value': self.config.file_name.replace('\\', '/')}) - return tags \ No newline at end of file + #tags.append({'Key': 'deployer:config', 'Value': self.config.file_name.replace('\\', '/')}) + return tags From be4f7cf39ac98c075d19b22b6853a2e928efab0b Mon Sep 17 00:00:00 2001 From: Jason Brown Date: Thu, 30 Apr 2020 13:34:29 -0400 Subject: [PATCH 06/36] Fixed issue with config data formatting --- deployer/configuration.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/deployer/configuration.py b/deployer/configuration.py index 52fcb72..eca96a5 100644 --- a/deployer/configuration.py +++ b/deployer/configuration.py @@ -5,6 +5,7 @@ from collections import MutableMapping from time import sleep from boto3.session import Session +from copy import deepcopy class Config(object): def __init__(self, profile, file_name=None): @@ -117,11 +118,10 @@ def _create_state_table(self): def _update_state_table(self, data): #Loop over stacks + stackdata = deepcopy(data) for stackname in data.keys(): - logger.info("Updating stack: {}".format(stackname)) - #stackconfig = data[stackname] - stackconfig = self._recursive_dynamo_conversion(data[stackname]) - logger.info(stackconfig) + + stackconfig = self._recursive_dynamo_conversion(stackdata[stackname]) #Set up the arguments kwargs = { From a58cba00390cc0b1c29cd22c0c491650f4f02005 Mon Sep 17 00:00:00 2001 From: Jason Brown Date: Mon, 4 May 2020 15:45:01 -0400 Subject: [PATCH 07/36] Fixed issue with retrieving config from the Dynamo table --- deployer/__init__.py | 3 +-- deployer/configuration.py | 34 +++++++++++++++++++++++++--------- 2 files changed, 26 insertions(+), 11 deletions(-) diff --git a/deployer/__init__.py b/deployer/__init__.py index d857159..48a7393 100755 --- a/deployer/__init__.py +++ b/deployer/__init__.py @@ -138,7 +138,7 @@ def main(): # S3 bucket to sync to bucket = CloudtoolsBucket(session, config_object.get_config_att('sync_dest_bucket', None)) - + # Check whether stack is a stack set or not and assign corresponding object if(len(config_object.get_config_att('regions', [])) > 0 or len(config_object.get_config_att('accounts', [])) > 0): env_stack = StackSet(session, stack, config_object, bucket, arguements) @@ -146,7 +146,6 @@ def main(): if args.timeout and args.execute not in ['create', 'upsert']: logger.warning("Timeout specified but action is not 'create'. Timeout will be ignored.") env_stack = Stack(session, stack, config_object, bucket, arguements) - try: # Sync files to S3 diff --git a/deployer/configuration.py b/deployer/configuration.py index eca96a5..5e04b8e 100644 --- a/deployer/configuration.py +++ b/deployer/configuration.py @@ -30,15 +30,16 @@ def _get_config(self, file_name=None): #Since it doesn't exist, create it self._create_state_table() - #Retrieve data from table and format it + #Retrieve data from table scan_resp = self.dynamo.scan(TableName=self.table_name) + #Format the data data = {} for item in scan_resp['Items']: #Each item represents a stack stackname = item['stackname']['S'] stackconfig = item['stackconfig']['M'] - data[stackname] = stackconfig + data[stackname] = self._recursive_dynamo_to_data(stackconfig) except Exception as e: msg = str(e) @@ -94,7 +95,7 @@ def _create_state_table(self): #Create Dynamo DB state table try: - logger.info("Attempting to create table") + logger.info("Attempting to create state table") response = self.dynamo.create_table(**kwargs) #Waiting for the table to exist @@ -121,7 +122,7 @@ def _update_state_table(self, data): stackdata = deepcopy(data) for stackname in data.keys(): - stackconfig = self._recursive_dynamo_conversion(stackdata[stackname]) + stackconfig = self._recursive_data_to_dynamo(stackdata[stackname]) #Set up the arguments kwargs = { @@ -133,7 +134,6 @@ def _update_state_table(self, data): }, "UpdateExpression": "set stackconfig = :val", "ExpressionAttributeValues": { - #":val": {"M": stackconfig} ":val": stackconfig } } @@ -147,21 +147,37 @@ def _update_state_table(self, data): return - def _recursive_dynamo_conversion(self, param): + def _recursive_data_to_dynamo(self, param): if isinstance(param, dict): paramdict = {} for key in param.keys(): - paramdict[key] = self._recursive_dynamo_conversion(param[key]) + paramdict[key] = self._recursive_data_to_dynamo(param[key]) return {'M': paramdict} elif isinstance(param, list): - #paramlist = self._recursive_dynamo_conversion(item) for item in param - return {'L': [ self._recursive_dynamo_conversion(item) for item in param ] } + return {'L': [ self._recursive_data_to_dynamo(item) for item in param ] } #For everything else, force it to be a string type for Dynamo return {'S': param} + def _recursive_dynamo_to_data(self, param): + if isinstance(param, dict): + paramdict = {} + for key in param.keys(): + if key == 'S': + return param[key] + elif key == 'L': + newlist = [self._recursive_dynamo_to_data(item) for item in param[key]] + return newlist + elif key == 'M': + return self._recursive_dynamo_to_data(param[key]) + else: + paramdict[key] = self._recursive_dynamo_to_data(param[key]) + return paramdict + + return param + def list_stacks(self): #This includes global settings as a stack return self.config.keys() From 6fbd24d516c5f2496efdc3277320267326700e0d Mon Sep 17 00:00:00 2001 From: Jason Brown Date: Tue, 5 May 2020 14:40:47 -0400 Subject: [PATCH 08/36] LambdaPrep class now is passed the sync base and lambda dirs from config object --- deployer/__init__.py | 5 ++++- deployer/configuration.py | 5 +++++ deployer/lambda_prep.py | 23 ++++------------------- 3 files changed, 13 insertions(+), 20 deletions(-) diff --git a/deployer/__init__.py b/deployer/__init__.py index 48a7393..055730d 100755 --- a/deployer/__init__.py +++ b/deployer/__init__.py @@ -118,11 +118,14 @@ def main(): #Setting the stack context for config object config_object.set_master_stack(stack) + config_object.merge_params(params) # Build lambdas on `-z` if args.zip_lambdas: logger.info("Building lambdas for stack: " + stack) - LambdaPrep(args.config, args.stack).zip_lambdas() + lambda_dirs = config_object.get_config_att('lambda_dirs', []) + sync_base = config_object.get_config_att('sync_base', '.') + LambdaPrep(sync_base, lambda_dirs).zip_lambdas() # AWS Session object session = Session(profile_name=args.profile, region_name=config_object.get_config_att('region')) diff --git a/deployer/configuration.py b/deployer/configuration.py index 5e04b8e..f065375 100644 --- a/deployer/configuration.py +++ b/deployer/configuration.py @@ -193,6 +193,11 @@ def _dict_merge(self, old, new): merged = old.copy() merged.update(new) return merged + + def merge_params(self, params): + logger.info("Params:") + logger.info(params) + return def build_params(self, session, stack_name, release, params, temp_file): # create parameters from the config.yml file diff --git a/deployer/lambda_prep.py b/deployer/lambda_prep.py index c1b9028..713cd2c 100755 --- a/deployer/lambda_prep.py +++ b/deployer/lambda_prep.py @@ -8,12 +8,10 @@ class LambdaPrep: - def __init__(self, config_file, environment): - self.config_file = config_file - self.config = self.get_config(config_file) - self.environment = environment - self.lambda_dirs = self.get_config_att('lambda_dirs', []) - self.sync_base = self.get_config_att('sync_base', '.') + def __init__(self, sync_base, lambda_dirs): + + self.lambda_dirs = lambda_dirs + self.sync_base = sync_base if not isinstance(self.lambda_dirs, list): logger.error("Attribute 'lambda_dirs' must be a list.") @@ -21,19 +19,6 @@ def __init__(self, config_file, environment): elif not self.lambda_dirs: logger.warning("Lambda packaging requested but no directories specified with the 'lambda_dirs' attribute") - def get_config(self, config): - with open(config) as f: - data = yaml.safe_load(f) - return data - - def get_config_att(self, key, default=None, required=False): - base = self.config.get('global', {}).get(key, None) - base = self.config.get(self.environment).get(key, base) - if required and base is None: - logger.error("Required attribute '{}' not found in config '{}'.".format(key, self.config_file)) - exit(3) - return base if base is not None else default - # zip_lambdas() will traverse through our configured lambda_dirs array, # create a temp lambda directory, install necessary dependencies, # zip it, move it, and cleanup all temp artifacts From 1f1a087c0857c5770c2fb525c29a3d5649c0d72c Mon Sep 17 00:00:00 2001 From: Jason Brown Date: Tue, 5 May 2020 14:58:58 -0400 Subject: [PATCH 09/36] Fixed issue with merging parameter overrides into Dynamo config --- deployer/__init__.py | 3 ++- deployer/configuration.py | 16 ++++++++++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/deployer/__init__.py b/deployer/__init__.py index 055730d..45f4195 100755 --- a/deployer/__init__.py +++ b/deployer/__init__.py @@ -118,7 +118,8 @@ def main(): #Setting the stack context for config object config_object.set_master_stack(stack) - config_object.merge_params(params) + if args.param: + config_object.merge_params(params) # Build lambdas on `-z` if args.zip_lambdas: diff --git a/deployer/configuration.py b/deployer/configuration.py index f065375..1f9576d 100644 --- a/deployer/configuration.py +++ b/deployer/configuration.py @@ -195,8 +195,20 @@ def _dict_merge(self, old, new): return merged def merge_params(self, params): - logger.info("Params:") - logger.info(params) + + param_data = { + self.stack: { + "parameters": params + } + } + + #Compare data from state table and file, update state table data with file data if different + updated_config = self._dict_merge(self.config, param_data) + + #Update Dynamo table + self._update_state_table(updated_config) + self.config = updated_config + return def build_params(self, session, stack_name, release, params, temp_file): From 0bfd32aaa747bba45a36fff27a12fc14c48c3d91 Mon Sep 17 00:00:00 2001 From: Jason Brown Date: Wed, 6 May 2020 15:57:32 -0400 Subject: [PATCH 10/36] Fixed issue with sending empty strings in config to dynamo --- deployer/configuration.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/deployer/configuration.py b/deployer/configuration.py index 1f9576d..bf3d638 100644 --- a/deployer/configuration.py +++ b/deployer/configuration.py @@ -152,7 +152,8 @@ def _recursive_data_to_dynamo(self, param): if isinstance(param, dict): paramdict = {} for key in param.keys(): - paramdict[key] = self._recursive_data_to_dynamo(param[key]) + if param[key] != '': + paramdict[key] = self._recursive_data_to_dynamo(param[key]) return {'M': paramdict} elif isinstance(param, list): return {'L': [ self._recursive_data_to_dynamo(item) for item in param ] } From 52b680da66270f9f6ee4563822eeb97eb39b112b Mon Sep 17 00:00:00 2001 From: Jason Brown Date: Thu, 7 May 2020 15:21:20 -0400 Subject: [PATCH 11/36] Fixed error checking, region is not hard-coded and updated table name --- deployer/configuration.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/deployer/configuration.py b/deployer/configuration.py index bf3d638..78a782d 100644 --- a/deployer/configuration.py +++ b/deployer/configuration.py @@ -6,16 +6,16 @@ from time import sleep from boto3.session import Session from copy import deepcopy +from datetime import datetime class Config(object): def __init__(self, profile, file_name=None): - self.region = "us-east-1" - self.table_name = "CFN-Deployer" + self.table_name = "CloudFormation-Deployer" self.profile = profile self.file_name = file_name #Create boto3 session and dynamo client - self.session = Session(profile_name=self.profile, region_name=self.region) + self.session = Session(profile_name=self.profile) self.dynamo = self.session.client('dynamodb') self.config = self._get_config(file_name) @@ -27,6 +27,12 @@ def _get_config(self, file_name=None): #Check for Dynamo state table if not self._table_exists(): + + #We must have a config file to populate the table + if not self.file_name: + logger.error("When creating a new state table, --config option is required") + exit(3) + #Since it doesn't exist, create it self._create_state_table() From 7299b3b788aaaa96bb9476f6fea8f09efbb81e7d Mon Sep 17 00:00:00 2001 From: Jason Brown Date: Fri, 8 May 2020 14:02:18 -0400 Subject: [PATCH 12/36] Updated dynamo table to include timestamp and updated queries to get the most recent config --- deployer/__init__.py | 7 +- deployer/configuration.py | 219 +++++++++++++++++++++++--------------- 2 files changed, 137 insertions(+), 89 deletions(-) diff --git a/deployer/__init__.py b/deployer/__init__.py index 45f4195..b2b0a71 100755 --- a/deployer/__init__.py +++ b/deployer/__init__.py @@ -117,10 +117,11 @@ def main(): logger.info("Running " + colors['underline'] + str(args.execute) + colors['reset'] + " on stack: " + colors['stack'] + stack + colors['reset']) #Setting the stack context for config object - config_object.set_master_stack(stack) if args.param: - config_object.merge_params(params) - + config_object.get_stack_config(stack, params) + else: + config_object.get_stack_config(stack) + # Build lambdas on `-z` if args.zip_lambdas: logger.info("Building lambdas for stack: " + stack) diff --git a/deployer/configuration.py b/deployer/configuration.py index 78a782d..7f767a1 100644 --- a/deployer/configuration.py +++ b/deployer/configuration.py @@ -13,46 +13,33 @@ def __init__(self, profile, file_name=None): self.table_name = "CloudFormation-Deployer" self.profile = profile self.file_name = file_name + self.file_data = self._get_file_data(file_name) #Create boto3 session and dynamo client self.session = Session(profile_name=self.profile) self.dynamo = self.session.client('dynamodb') - self.config = self._get_config(file_name) - - def _get_config(self, file_name=None): - - #Create session - try: - - #Check for Dynamo state table - if not self._table_exists(): - - #We must have a config file to populate the table - if not self.file_name: - logger.error("When creating a new state table, --config option is required") - exit(3) - - #Since it doesn't exist, create it - self._create_state_table() - - #Retrieve data from table - scan_resp = self.dynamo.scan(TableName=self.table_name) + #Create state table if necessary + if not self._table_exists(): - #Format the data - data = {} - for item in scan_resp['Items']: - #Each item represents a stack - stackname = item['stackname']['S'] - stackconfig = item['stackconfig']['M'] - data[stackname] = self._recursive_dynamo_to_data(stackconfig) + #We must have a config file to populate the table + if not self.file_name: + logger.error("When creating a new state table, --config option is required") + exit(3) - except Exception as e: - msg = str(e) - logger.error("Failed to retrieve data from dynamo state table {}: {}".format(self.table_name,msg)) - exit(3) + #Since it doesn't exist, create it + self._create_state_table() + + self.config = {} + + self.stack_list = self._get_stacks() + + ### WARNING - THE FOLLOWING DOESN'T HANDLE OVERRIDE PARAMS FOR GLOBAL ### + self.get_stack_config("global") + + def _get_file_data(self, file_name=None): + file_data = None - #Check for file_name if file_name: try: with open(file_name) as f: @@ -61,16 +48,92 @@ def _get_config(self, file_name=None): msg = str(e) logger.error("Failed to retrieve data from config file {}: {}".format(file_name,msg)) exit(3) + + return file_data + + def _get_stacks(self): + + file_list = [] + dynamo_list = [] + + if self.file_data: + file_list = [ key for key in self.file_data.keys() ] + + try: + #Need to get the distinct stacks in the table + #Since Dynamo isn't set up to handle this natively, we need + # to read the whole table and filter the results ourselves + dynamo_args = { + 'TableName': self.table_name, + 'ProjectionExpression': "stackname" + } + scan_resp = self.dynamo.scan(**dynamo_args) + dynamo_list = [ item['stackname']['S'] for item in scan_resp['Items'] ] - #Compare data from state table and file, update state table data with file data if different - finalstate = self._dict_merge(data, file_data) - data = finalstate + #Get unique items + dynamo_list = list(set(dynamo_list)) - #Update Dynamo table - self._update_state_table(data) + except Exception as e: + msg = str(e) + logger.error("Failed to retrieve stacks from dynamo state table {}: {}".format(self.table_name,msg)) + exit(3) + + #Merge the lists + stacklist = list(set(file_list+dynamo_list)) + + return stacklist + + def get_stack_config(self, stack_context, params=None): + #Set the stack context + self.stack = stack_context + + #Get the most recent stack config from Dynamo + try: + dynamo_args = { + 'TableName': self.table_name, + 'KeyConditionExpression': "#sn = :sn", + 'ExpressionAttributeNames': { + '#sn': 'stackname' + }, + 'ExpressionAttributeValues': { + ':sn': { + 'S': self.stack + } + }, + 'ScanIndexForward'=False, + 'Limit': 1 + } + + query_resp = self.dynamo.query(**dynamo_args) + item = query_resp['Items'][0] + except Exception as e: + msg = str(e) + logger.error("Failed to retrieve data from dynamo state table {} for stack {}: {}".format(self.table_name, stack_context, msg)) + exit(3) + + #Format the stack config data + data = self._recursive_dynamo_to_data(stackconfig) + + if params: + #Merge the override params for the stack if applicable + param_data = { + "parameters": params + } + merged_params = self._dict_merge(data, param_data) + data = merged_params + + if self.file_data: + #Merge the file data for the stack if applicable + merged_file = self._dict_merge(data, self.file_data[stackname]) + data = merged_file + + if params or self.file_data: + self._update_state_table(stackname, data) + + self.config[stackname] = data + + return finalstate - return data - def _table_exists(self): resp_tables = self.dynamo.list_tables() if self.table_name in resp_tables['TableNames']: @@ -87,6 +150,10 @@ def _create_state_table(self): { 'AttributeName': 'stackname', 'AttributeType': 'S' + }, + { + 'AttributeName': 'timestamp', + 'AttributeType': 'S' } ], 'TableName': self.table_name, @@ -95,6 +162,10 @@ def _create_state_table(self): 'AttributeName': 'stackname', 'KeyType': 'HASH' }, + { + 'AttributeName': 'timestamp', + 'KeyType': 'RANGE' + } ], 'BillingMode': 'PAY_PER_REQUEST' } @@ -122,35 +193,31 @@ def _create_state_table(self): return - def _update_state_table(self, data): + def _update_state_table(self, stack, data): - #Loop over stacks + #Convert to Dynamo params stackdata = deepcopy(data) - for stackname in data.keys(): - - stackconfig = self._recursive_data_to_dynamo(stackdata[stackname]) - - #Set up the arguments - kwargs = { - "TableName": self.table_name, - "Key": { - "stackname": { - "S": stackname - } - }, - "UpdateExpression": "set stackconfig = :val", - "ExpressionAttributeValues": { - ":val": stackconfig - } - } - - try: - response = self.dynamo.update_item(**kwargs) - except Exception as e: - msg = str(e) - logger.error("Failed to update data to dynamo state table {}: {}".format(self.table_name,msg)) - exit(3) + stack_config = self._recursive_data_to_dynamo(stackdata) + timestamp = datetime.utcnow().strftime("%Y-%m-%d-%H:%M:%S.%f") + item = { + "stackname": { "S": stack }, + "timestamp": { "S": timestamp}, + "stackconfig": { "M": stackconfig} + } + + #Set up the API arguments + kwargs = { + "TableName": self.table_name, + "Item": item + } + try: + response = self.dynamo.put_item(**kwargs) + except Exception as e: + msg = str(e) + logger.error("Failed to update data to dynamo state table {}: {}".format(self.table_name,msg)) + exit(3) + return def _recursive_data_to_dynamo(self, param): @@ -187,7 +254,7 @@ def _recursive_dynamo_to_data(self, param): def list_stacks(self): #This includes global settings as a stack - return self.config.keys() + return self.stack_list def _dict_merge(self, old, new): #Recursively go through the nested dictionaries, with values in @@ -201,23 +268,6 @@ def _dict_merge(self, old, new): merged.update(new) return merged - def merge_params(self, params): - - param_data = { - self.stack: { - "parameters": params - } - } - - #Compare data from state table and file, update state table data with file data if different - updated_config = self._dict_merge(self.config, param_data) - - #Update Dynamo table - self._update_state_table(updated_config) - self.config = updated_config - - return - def build_params(self, session, stack_name, release, params, temp_file): # create parameters from the config.yml file self.parameter_file = "%s-params.json" % stack_name @@ -287,6 +337,3 @@ def get_config_att(self, key, default=None, required=False): def get_config(self): return self.config - def set_master_stack(self, master_stack): - self.stack = master_stack - return From 8079e623a05c1f0cd3f6766b6a0d3ff732ad7872 Mon Sep 17 00:00:00 2001 From: Jason Brown Date: Mon, 11 May 2020 09:26:27 -0400 Subject: [PATCH 13/36] Fixed bug with config history --- deployer/configuration.py | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/deployer/configuration.py b/deployer/configuration.py index 7f767a1..dd06943 100644 --- a/deployer/configuration.py +++ b/deployer/configuration.py @@ -100,19 +100,23 @@ def get_stack_config(self, stack_context, params=None): 'S': self.stack } }, - 'ScanIndexForward'=False, + 'ScanIndexForward': False, 'Limit': 1 } query_resp = self.dynamo.query(**dynamo_args) - item = query_resp['Items'][0] + except Exception as e: msg = str(e) logger.error("Failed to retrieve data from dynamo state table {} for stack {}: {}".format(self.table_name, stack_context, msg)) exit(3) - #Format the stack config data - data = self._recursive_dynamo_to_data(stackconfig) + data = {} + if query_resp['Count'] > 0: + #Format the stack config data + item = query_resp['Items'][0] + data = self._recursive_dynamo_to_data(item) + data = data['stackconfig'] if params: #Merge the override params for the stack if applicable @@ -124,15 +128,15 @@ def get_stack_config(self, stack_context, params=None): if self.file_data: #Merge the file data for the stack if applicable - merged_file = self._dict_merge(data, self.file_data[stackname]) + merged_file = self._dict_merge(data, self.file_data[self.stack]) data = merged_file if params or self.file_data: - self._update_state_table(stackname, data) + self._update_state_table(self.stack, data) - self.config[stackname] = data - - return finalstate + self.config[self.stack] = data + + return data def _table_exists(self): resp_tables = self.dynamo.list_tables() @@ -202,7 +206,7 @@ def _update_state_table(self, stack, data): item = { "stackname": { "S": stack }, "timestamp": { "S": timestamp}, - "stackconfig": { "M": stackconfig} + "stackconfig": stack_config } #Set up the API arguments From 3734dd815a003e846bb61aa110147fdd8734e7a5 Mon Sep 17 00:00:00 2001 From: Jason Brown Date: Mon, 11 May 2020 11:45:05 -0400 Subject: [PATCH 14/36] Config object is now done on a per-stack basis --- deployer/__init__.py | 32 ++++++++++++++++----- deployer/configuration.py | 60 ++++++++------------------------------- 2 files changed, 37 insertions(+), 55 deletions(-) diff --git a/deployer/__init__.py b/deployer/__init__.py index b2b0a71..8e14a8f 100755 --- a/deployer/__init__.py +++ b/deployer/__init__.py @@ -71,6 +71,10 @@ def main(): # Validate arguements and parameters options_broken = False params = {} + if args.all: + if not args.config: + print(colors['warning'] + "Must Specify config flag!" + colors['reset']) + options_broken = True if not args.all: if not args.execute: print(colors['warning'] + "Must Specify execute flag!" + colors['reset']) @@ -98,15 +102,22 @@ def main(): console_logger.setLevel(logging.ERROR) try: - # Create deployer config object - config_object = Config(args.profile, args.config) # Load stacks into queue stackQueue = [] if not args.all: stackQueue = [args.stack] else: - for stack in config_object.list_stacks(): + #Load config, get stacks + try: + with open(args.config) as f: + file_data = ruamel.yaml.safe_load(f) + except Exception as e: + msg = str(e) + logger.error("Failed to retrieve data from config file {}: {}".format(file_name,msg)) + exit(3) + + for stack in file_data.keys(): if stack[0] != "global": stackQueue = find_deploy_path(config_object.get_config(), stack[0], stackQueue) @@ -116,12 +127,19 @@ def main(): logger.info("Running " + colors['underline'] + str(args.execute) + colors['reset'] + " on stack: " + colors['stack'] + stack + colors['reset']) - #Setting the stack context for config object + # Create deployer config object + cargs = { + 'profile': args.profile, + 'stack_name': stack + } + if args.config: + cargs['file_name'] = args.config + if args.param: - config_object.get_stack_config(stack, params) - else: - config_object.get_stack_config(stack) + cargs['override_params'] = params + config_object = Config(**cargs) + # Build lambdas on `-z` if args.zip_lambdas: logger.info("Building lambdas for stack: " + stack) diff --git a/deployer/configuration.py b/deployer/configuration.py index dd06943..9a450f7 100644 --- a/deployer/configuration.py +++ b/deployer/configuration.py @@ -9,10 +9,12 @@ from datetime import datetime class Config(object): - def __init__(self, profile, file_name=None): + def __init__(self, profile, stack_name, file_name=None, override_params=None): self.table_name = "CloudFormation-Deployer" self.profile = profile + self.stack = stack_name self.file_name = file_name + self.file_data = self._get_file_data(file_name) #Create boto3 session and dynamo client @@ -31,11 +33,7 @@ def __init__(self, profile, file_name=None): self._create_state_table() self.config = {} - - self.stack_list = self._get_stacks() - - ### WARNING - THE FOLLOWING DOESN'T HANDLE OVERRIDE PARAMS FOR GLOBAL ### - self.get_stack_config("global") + self._get_stack_config() def _get_file_data(self, file_name=None): file_data = None @@ -51,41 +49,7 @@ def _get_file_data(self, file_name=None): return file_data - def _get_stacks(self): - - file_list = [] - dynamo_list = [] - - if self.file_data: - file_list = [ key for key in self.file_data.keys() ] - - try: - #Need to get the distinct stacks in the table - #Since Dynamo isn't set up to handle this natively, we need - # to read the whole table and filter the results ourselves - dynamo_args = { - 'TableName': self.table_name, - 'ProjectionExpression': "stackname" - } - scan_resp = self.dynamo.scan(**dynamo_args) - dynamo_list = [ item['stackname']['S'] for item in scan_resp['Items'] ] - - #Get unique items - dynamo_list = list(set(dynamo_list)) - - except Exception as e: - msg = str(e) - logger.error("Failed to retrieve stacks from dynamo state table {}: {}".format(self.table_name,msg)) - exit(3) - - #Merge the lists - stacklist = list(set(file_list+dynamo_list)) - - return stacklist - - def get_stack_config(self, stack_context, params=None): - #Set the stack context - self.stack = stack_context + def _get_stack_config(self, params=None): #Get the most recent stack config from Dynamo try: @@ -127,9 +91,13 @@ def get_stack_config(self, stack_context, params=None): data = merged_params if self.file_data: - #Merge the file data for the stack if applicable - merged_file = self._dict_merge(data, self.file_data[self.stack]) - data = merged_file + #Merge the file data for the stack if applicable, global first + if 'global' in self.file_data: + merged_global = self._dict_merge(data, self.file_data['global']) + data = merged_global + if self.stack in self.file_data: + merged_file = self._dict_merge(data, self.file_data[self.stack]) + data = merged_file if params or self.file_data: self._update_state_table(self.stack, data) @@ -256,10 +224,6 @@ def _recursive_dynamo_to_data(self, param): return param - def list_stacks(self): - #This includes global settings as a stack - return self.stack_list - def _dict_merge(self, old, new): #Recursively go through the nested dictionaries, with values in # 'new' overwriting the values in 'old' for the same key From 93574e3c773adf94a52262630f297baf1ac6c550 Mon Sep 17 00:00:00 2001 From: Jason Brown Date: Mon, 11 May 2020 14:00:57 -0400 Subject: [PATCH 15/36] Override parameters are now passed through into the stack config --- deployer/configuration.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deployer/configuration.py b/deployer/configuration.py index 9a450f7..a00e3cf 100644 --- a/deployer/configuration.py +++ b/deployer/configuration.py @@ -33,7 +33,7 @@ def __init__(self, profile, stack_name, file_name=None, override_params=None): self._create_state_table() self.config = {} - self._get_stack_config() + self._get_stack_config(override_params) def _get_file_data(self, file_name=None): file_data = None From ee1aa5152dee06b5534de2144ed86942e42ae030 Mon Sep 17 00:00:00 2001 From: Jason Brown Date: Mon, 11 May 2020 16:00:54 -0400 Subject: [PATCH 16/36] Moved caller, git commit and git origin into config object and top level of dynamo state table --- deployer/cloudformation.py | 15 ------------ deployer/configuration.py | 49 +++++++++++++++++++++++++++++++++++++- deployer/stack.py | 30 +++-------------------- deployer/stack_sets.py | 29 +++------------------- 4 files changed, 54 insertions(+), 69 deletions(-) diff --git a/deployer/cloudformation.py b/deployer/cloudformation.py index 7f15735..c0a925a 100644 --- a/deployer/cloudformation.py +++ b/deployer/cloudformation.py @@ -1,5 +1,4 @@ #!/usr/bin/env python -import git from abc import ABCMeta, abstractmethod from deployer.logger import logger @@ -52,20 +51,6 @@ def reload_stack_status(self): def status(self): pass - def get_repository(self, base): - try: - return git.Repo(base, search_parent_directories=True) - except git.exc.InvalidGitRepositoryError: - return None - - def get_repository_origin(self, repository): - try: - origin = repository.remotes.origin.url - return origin.split('@', 1)[-1] if origin else None - except (StopIteration, ValueError): - return None - return None - def get_template_body(self, bucket, template): if not bucket: try: diff --git a/deployer/configuration.py b/deployer/configuration.py index a00e3cf..09506c8 100644 --- a/deployer/configuration.py +++ b/deployer/configuration.py @@ -7,6 +7,7 @@ from boto3.session import Session from copy import deepcopy from datetime import datetime +import git class Config(object): def __init__(self, profile, stack_name, file_name=None, override_params=None): @@ -33,6 +34,7 @@ def __init__(self, profile, stack_name, file_name=None, override_params=None): self._create_state_table() self.config = {} + self._get_stack_config(override_params) def _get_file_data(self, file_name=None): @@ -99,6 +101,18 @@ def _get_stack_config(self, params=None): merged_file = self._dict_merge(data, self.file_data[self.stack]) data = merged_file + sts = self.session.client('sts') + self.identity_arn = sts.get_caller_identity().get('Arn', '') + + # Load values from methods for config lookup + self.base = data.get('sync_base', '.') + self.repository = self.get_repository(self.base) + self.commit = self.repository.head.object.hexsha if self.repository else 'null' + self.origin = self.get_repository_origin(self.repository) if self.repository else 'null' + + if not data.get('release', False): + data['release'] = self.commit + if params or self.file_data: self._update_state_table(self.stack, data) @@ -174,7 +188,10 @@ def _update_state_table(self, stack, data): item = { "stackname": { "S": stack }, "timestamp": { "S": timestamp}, - "stackconfig": stack_config + "stackconfig": stack_config, + "caller": { "S": self.identity_arn}, + "commit": { "S": self.commit}, + "origin": { "S": self.origin}, } #Set up the API arguments @@ -236,6 +253,22 @@ def _dict_merge(self, old, new): merged.update(new) return merged + def construct_tags(self): + tags = self.get_config_att('tags') + if tags: + tags = [ { 'Key': key, 'Value': value } for key, value in tags.items() ] + if len(tags) > 47: + raise ValueError('Resources tag limit is 50, you have provided more than 47 tags. Please limit your tagging, save room for name and deployer tags.') + else: + tags = [] + tags.append({'Key': 'deployer:stack', 'Value': self.stack}) + tags.append({'Key': 'deployer:caller', 'Value': self.identity_arn}) + tags.append({'Key': 'deployer:git:commit', 'Value': self.commit}) + tags.append({'Key': 'deployer:git:origin', 'Value': self.origin}) + if self.file_name: + tags.append({'Key': 'deployer:config', 'Value': self.file_name.replace('\\', '/')}) + return tags + def build_params(self, session, stack_name, release, params, temp_file): # create parameters from the config.yml file self.parameter_file = "%s-params.json" % stack_name @@ -293,6 +326,20 @@ def build_params(self, session, stack_name, release, params, temp_file): return_params.remove(item) logger.info("Parameters Created") return return_params + + def get_repository(self, base): + try: + return git.Repo(base, search_parent_directories=True) + except git.exc.InvalidGitRepositoryError: + return None + + def get_repository_origin(self, repository): + try: + origin = repository.remotes.origin.url + return origin.split('@', 1)[-1] if origin else None + except (StopIteration, ValueError): + return None + return None def get_config_att(self, key, default=None, required=False): base = self.config.get('global', {}).get(key, None) diff --git a/deployer/stack.py b/deployer/stack.py index 3d051be..bad8b03 100644 --- a/deployer/stack.py +++ b/deployer/stack.py @@ -30,25 +30,17 @@ def __init__(self, session, stack, config, bucket, args = {}): # Load values from config self.stack_name = self.config.get_config_att('stack_name', required=True) - self.base = self.config.get_config_att('sync_base', '.') - - # Load values from methods for config lookup - self.repository = self.get_repository(self.base) - self.commit = self.repository.head.object.hexsha if self.repository else 'null' # Load values from config - self.release = self.config.get_config_att('release', self.commit).replace('/','.') + self.release = self.config.get_config_att('release').replace('/','.') self.template = self.config.get_config_att('template', required=True) self.timeout = self.config.get_config_att('timeout') if not self.timed_out else None self.transforms = self.config.get_config_att('transforms') # Intialize objects self.client = self.session.client('cloudformation') - self.sts = self.session.client('sts') # Load values from methods - self.origin = self.get_repository_origin(self.repository) if self.repository else 'null' - self.identity_arn = self.sts.get_caller_identity().get('Arn', '') self.template_url = self.bucket.construct_template_url(self.config, self.stack, self.release, self.template) # self.construct_template_url() self.template_file = self.bucket.get_template_file(self.config, self.stack) self.template_body = self.bucket.get_template_body(self.config, self.template) @@ -71,22 +63,6 @@ def reload_change_set_status(self, change_set_name): self.change_set_status = 'False' return self.change_set_status - def construct_tags(self): - tags = self.config.get_config_att('tags') - if tags: - tags = [ { 'Key': key, 'Value': value } for key, value in tags.items() ] - if len(tags) > 47: - raise ValueError('Resources tag limit is 50, you have provided more than 47 tags. Please limit your tagging, save room for name and deployer tags.') - else: - tags = [] - tags.append({'Key': 'deployer:stack', 'Value': self.stack}) - tags.append({'Key': 'deployer:caller', 'Value': self.identity_arn}) - tags.append({'Key': 'deployer:git:commit', 'Value': self.commit}) - tags.append({'Key': 'deployer:git:origin', 'Value': self.origin}) - #tags.append({'Key': 'deployer:config', 'Value': self.config.file_name.replace('\\', '/')}) - return tags - - def create_waiter(self, start_time): waiter = self.client.get_waiter('stack_create_complete') logger.info("Creation Started") @@ -275,7 +251,7 @@ def create_stack(self): "StackName": self.stack_name, "Parameters": self.config.build_params(self.session, self.stack, self.release, self.params, self.template_file), "DisableRollback": self.disable_rollback, - "Tags": self.construct_tags(), + "Tags": self.config.construct_tags(), "Capabilities": [ 'CAPABILITY_IAM', 'CAPABILITY_NAMED_IAM', @@ -302,7 +278,7 @@ def update_stack(self): args = { "StackName": self.stack_name, "Parameters": self.config.build_params(self.session, self.stack, self.release, self.params, self.template_file), - "Tags": self.construct_tags(), + "Tags": self.config.construct_tags(), "Capabilities": [ 'CAPABILITY_IAM', 'CAPABILITY_NAMED_IAM', diff --git a/deployer/stack_sets.py b/deployer/stack_sets.py index 00c0aa1..22ef403 100644 --- a/deployer/stack_sets.py +++ b/deployer/stack_sets.py @@ -26,13 +26,8 @@ def __init__(self, session, stack, config, bucket, args = {}): self.print_events = args.get('print_events', False) - # Load values from methods for config lookup - self.base = self.config.get_config_att('sync_base', '.') - self.repository = self.get_repository(self.base) - self.commit = self.repository.head.object.hexsha if self.repository else 'null' - # Load values from config - self.release = self.config.get_config_att('release', self.commit).replace('/','.') + self.release = self.config.get_config_att('release').replace('/','.') self.template = self.config.get_config_att('template', required=True) self.account = self.config.get_config_att('account', None) self.accounts = self.config.get_config_att('accounts', None) @@ -42,11 +37,8 @@ def __init__(self, session, stack, config, bucket, args = {}): # Intialize objects self.client = self.session.client('cloudformation') - self.sts = self.session.client('sts') # Load values from methods - self.origin = self.get_repository_origin(self.repository) if self.repository else 'null' - self.identity_arn = self.sts.get_caller_identity().get('Arn', '') self.template_url = self.bucket.construct_template_url(self.config, self.stack, self.release, self.template) # self.construct_template_url() self.template_file = self.bucket.get_template_file(self.config, self.stack) self.template_body = self.bucket.get_template_body(self.config, self.template) @@ -122,7 +114,7 @@ def create_stack(self): ], "Parameters": self.config.build_params(self.session, self.stack, self.release, self.params, self.template_file), 'StackSetName': self.stack_name, - "Tags": self.construct_tags() + "Tags": self.config.construct_tags() } if self.template_body: logger.info("Using local template due to null template bucket") @@ -188,7 +180,7 @@ def update_stack(self): ], "Parameters": self.config.build_params(self.session, self.stack, self.release, self.params, self.template_file), 'StackSetName': self.stack_name, - "Tags": self.construct_tags(), + "Tags": self.config.construct_tags(), } args.update({'AdministrationRoleARN': self.administration_role} if self.administration_role else {}) @@ -291,18 +283,3 @@ def delete_stack_instances(self, accounts, regions): logger.info("Deleting " + str(len(accounts) * len(regions)) + " stack instances...") result = self.client.delete_stack_instances(StackSetName=self.stack_name, Accounts=accounts, Regions=regions, RetainStacks=False) return result['OperationId'] - - def construct_tags(self): - tags = self.config.get_config_att('tags') - if tags: - tags = [ { 'Key': key, 'Value': value } for key, value in tags.items() ] - if len(tags) > 47: - raise ValueError('Resources tag limit is 50, you have provided more than 47 tags. Please limit your tagging, save room for name and deployer tags.') - else: - tags = [] - tags.append({'Key': 'deployer:stack', 'Value': self.stack}) - tags.append({'Key': 'deployer:caller', 'Value': self.identity_arn}) - tags.append({'Key': 'deployer:git:commit', 'Value': self.commit}) - tags.append({'Key': 'deployer:git:origin', 'Value': self.origin}) - #tags.append({'Key': 'deployer:config', 'Value': self.config.file_name.replace('\\', '/')}) - return tags From c3301d67d239c83b003cad1b0448a152353abd41 Mon Sep 17 00:00:00 2001 From: Jason Brown Date: Mon, 11 May 2020 16:38:30 -0400 Subject: [PATCH 17/36] Added UsePreviousValue functionality --- deployer/configuration.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/deployer/configuration.py b/deployer/configuration.py index 09506c8..8c96a16 100644 --- a/deployer/configuration.py +++ b/deployer/configuration.py @@ -95,10 +95,12 @@ def _get_stack_config(self, params=None): if self.file_data: #Merge the file data for the stack if applicable, global first if 'global' in self.file_data: - merged_global = self._dict_merge(data, self.file_data['global']) + config_copy = self._handle_use_previous_value(self.file_data['global']) + merged_global = self._dict_merge(data, config_copy) data = merged_global if self.stack in self.file_data: - merged_file = self._dict_merge(data, self.file_data[self.stack]) + config_copy = self._handle_use_previous_value(self.file_data[self.stack]) + merged_file = self._dict_merge(data, config_copy) data = merged_file sts = self.session.client('sts') @@ -120,6 +122,16 @@ def _get_stack_config(self, params=None): return data + def _handle_use_previous_value(self, paramdict): + dict_copy = deepcopy(paramdict) + # First look for indicators to use previous value, remove it from the dict if it is true + for paramkey in dict_copy['parameters'].keys(): + if isinstance(dict_copy['parameters'][paramkey],dict): + if "UsePreviousValue" in dict_copy['parameters'][paramkey]: + if dict_copy['parameters'][paramkey]["UsePreviousValue"]: + dict_copy['parameters'].pop(paramkey) + return dict_copy + def _table_exists(self): resp_tables = self.dynamo.list_tables() if self.table_name in resp_tables['TableNames']: From 01342441d65766c6e0b275d2b5e0cac02b79c5a2 Mon Sep 17 00:00:00 2001 From: Jason Brown Date: Tue, 12 May 2020 10:26:18 -0400 Subject: [PATCH 18/36] Added YAML and JSON config export --- deployer/__init__.py | 29 ++++++++++++++++++++++++++++- deployer/configuration.py | 4 ++-- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/deployer/__init__.py b/deployer/__init__.py index 8e14a8f..6bf3580 100755 --- a/deployer/__init__.py +++ b/deployer/__init__.py @@ -1,6 +1,7 @@ #!/usr/bin/env python import argparse import json +import yaml import os from botocore.exceptions import ClientError from deployer.stack import Stack @@ -40,6 +41,8 @@ def main(): parser.add_argument("-D", "--debug", help="Sets logging level to DEBUG & enables traceback", action="store_true", dest="debug", default=False) parser.add_argument("-v", "--version", help='Print version number', action='store_true', dest='version') parser.add_argument("-T", "--timeout", type=int, help='Stack create timeout') + parser.add_argument("-O", "--export-yaml", help="Export stack config to specified YAML file.",default=None) + parser.add_argument("-o", "--export-json", help="Export stack config to specified JSON file.",default=None) parser.add_argument('--init', default=None, const='.', nargs='?', help='Initialize a skeleton directory') parser.add_argument("--disable-color", help='Disables color output', action='store_true', dest='no_color') @@ -139,7 +142,31 @@ def main(): cargs['override_params'] = params config_object = Config(**cargs) - + + #Export if specified + if args.export_json: + config_dict = config_object.get_config() + + try: + with open(args.export_json, 'w') as f: + j = json.dumps(config_dict, indent=4) + f.write(j) + except Exception as e: + msg = str(e) + logger.error("Failed to export data to JSON file {}: {}".format(args.export_json,msg)) + exit(3) + + if args.export_yaml: + config_dict = config_object.get_config() + + try: + with open(args.export_yaml, 'w') as f: + yaml.dump(config_dict, f, default_flow_style=False, allow_unicode=True) + except Exception as e: + msg = str(e) + logger.error("Failed to export data to YAML file {}: {}".format(args.export_yaml,msg)) + exit(3) + # Build lambdas on `-z` if args.zip_lambdas: logger.info("Building lambdas for stack: " + stack) diff --git a/deployer/configuration.py b/deployer/configuration.py index 8c96a16..b518262 100644 --- a/deployer/configuration.py +++ b/deployer/configuration.py @@ -241,14 +241,14 @@ def _recursive_dynamo_to_data(self, param): paramdict = {} for key in param.keys(): if key == 'S': - return param[key] + return str(param[key]) elif key == 'L': newlist = [self._recursive_dynamo_to_data(item) for item in param[key]] return newlist elif key == 'M': return self._recursive_dynamo_to_data(param[key]) else: - paramdict[key] = self._recursive_dynamo_to_data(param[key]) + paramdict[str(key)] = self._recursive_dynamo_to_data(param[key]) return paramdict return param From 31655fa73206622c5e40e4ae152c68e24a31f79f Mon Sep 17 00:00:00 2001 From: Jason Brown Date: Tue, 12 May 2020 12:00:26 -0400 Subject: [PATCH 19/36] Added versions to configs --- deployer/configuration.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/deployer/configuration.py b/deployer/configuration.py index b518262..47a81cc 100644 --- a/deployer/configuration.py +++ b/deployer/configuration.py @@ -34,7 +34,7 @@ def __init__(self, profile, stack_name, file_name=None, override_params=None): self._create_state_table() self.config = {} - + self.version = 0 self._get_stack_config(override_params) def _get_file_data(self, file_name=None): @@ -82,6 +82,8 @@ def _get_stack_config(self, params=None): #Format the stack config data item = query_resp['Items'][0] data = self._recursive_dynamo_to_data(item) + if 'version' in data and data['version'].isdigit(): + self.version = int(data['version']) data = data['stackconfig'] if params: @@ -197,8 +199,11 @@ def _update_state_table(self, stack, data): stackdata = deepcopy(data) stack_config = self._recursive_data_to_dynamo(stackdata) timestamp = datetime.utcnow().strftime("%Y-%m-%d-%H:%M:%S.%f") + #Increment version + self.version+=1 item = { "stackname": { "S": stack }, + "version": { "S": str(self.version)}, "timestamp": { "S": timestamp}, "stackconfig": stack_config, "caller": { "S": self.identity_arn}, From 3502a63f199159abf7db251fd352e8e2baed6b92 Mon Sep 17 00:00:00 2001 From: Jason Brown Date: Tue, 12 May 2020 13:15:05 -0400 Subject: [PATCH 20/36] Added the ability to list and get versions for a stack config --- deployer/__init__.py | 26 ++++++++++++- deployer/configuration.py | 77 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 102 insertions(+), 1 deletion(-) diff --git a/deployer/__init__.py b/deployer/__init__.py index 6bf3580..116ab80 100755 --- a/deployer/__init__.py +++ b/deployer/__init__.py @@ -43,6 +43,8 @@ def main(): parser.add_argument("-T", "--timeout", type=int, help='Stack create timeout') parser.add_argument("-O", "--export-yaml", help="Export stack config to specified YAML file.",default=None) parser.add_argument("-o", "--export-json", help="Export stack config to specified JSON file.",default=None) + parser.add_argument("-i", "--config-version", help="Execute ( list | get | set ) of stack config.") + parser.add_argument("-n", "--config-version-number", help="Specified config version, used with --config-version option.") parser.add_argument('--init', default=None, const='.', nargs='?', help='Initialize a skeleton directory') parser.add_argument("--disable-color", help='Disables color output', action='store_true', dest='no_color') @@ -79,7 +81,14 @@ def main(): print(colors['warning'] + "Must Specify config flag!" + colors['reset']) options_broken = True if not args.all: - if not args.execute: + if args.config_version: + if args.config_version != "list" and args.config_version != "set" and args.config_version != "get": + print(colors['warning'] + "config-version command '" + args.config_version + "' not recognized. Must be one of: list, set, get "+ colors['reset']) + options_broken = True + if (args.config_version == 'set' or args.config_version == 'get') and not args.config_version_number: + print(colors['warning'] + "config-version " + args.config_version + " requires config-version-number flag!" + colors['reset']) + options_broken = True + elif not args.execute: print(colors['warning'] + "Must Specify execute flag!" + colors['reset']) options_broken = True if not args.stack: @@ -143,6 +152,21 @@ def main(): config_object = Config(**cargs) + #Config Version Handling + if args.config_version: + if args.config_version == "list": + versions = config_object.list_versions() + for version in versions: + if 'version' in version: + print("Timestamp: {} Version: {}".format(version['timestamp'], version['version'])) + elif args.config_version == "get": + retrieved_config = config_object.get_version(args.config_version_number) + print(yaml.dump(retrieved_config,default_flow_style=False, allow_unicode=True)) + elif args.config_version == "set": + config_object.set_version(args.config_version_number) + + continue + #Export if specified if args.export_json: config_dict = config_object.get_config() diff --git a/deployer/configuration.py b/deployer/configuration.py index 47a81cc..ffa0986 100644 --- a/deployer/configuration.py +++ b/deployer/configuration.py @@ -226,6 +226,83 @@ def _update_state_table(self, stack, data): return + def list_versions(self): + + try: + dynamo_args = { + 'TableName': self.table_name, + 'KeyConditionExpression': "#sn = :sn", + 'ExpressionAttributeNames': { + '#sn': 'stackname', + "#tm": 'timestamp' + }, + 'ExpressionAttributeValues': { + ':sn': { + 'S': self.stack + } + }, + 'ProjectionExpression': "version, #tm", + 'ScanIndexForward': False + } + + query_resp = self.dynamo.query(**dynamo_args) + + except Exception as e: + msg = str(e) + logger.error("Failed to retrieve data from dynamo state table {} for stack {}: {}".format(self.table_name, self.stack, msg)) + exit(3) + + if query_resp['Count'] <= 0: + logger.error("Failed to retrieve versions from dynamo state table {} for stack {}: No versions exist".format(self.table_name, self.stack)) + exit(3) + + #Format the data + items = [] + for item in query_resp['Items']: + items.append(self._recursive_dynamo_to_data(item)) + + return items + + def get_version(self, version): + try: + dynamo_args = { + 'TableName': self.table_name, + 'KeyConditionExpression': "#sn = :sn", + 'FilterExpression': "#vn = :vn", + 'ExpressionAttributeNames': { + '#sn': 'stackname', + '#vn': 'version', + }, + 'ExpressionAttributeValues': { + ':sn': { + 'S': self.stack + }, + ':vn': { + 'S': version + } + }, + 'ScanIndexForward': False, + } + + query_resp = self.dynamo.query(**dynamo_args) + + except Exception as e: + msg = str(e) + logger.error("Failed to retrieve data from dynamo state table {} for stack {}: {}".format(self.table_name, self.stack, msg)) + exit(3) + + if query_resp['Count'] <= 0: + logger.error("Failed to retrieve versions from dynamo state table {} for stack {}: Version '{}' does not exist".format(self.table_name, self.stack, version)) + exit(3) + + #Format the data + item = self._recursive_dynamo_to_data(query_resp['Items'][0]) + + return item + + def set_version(self, version): + return + def _recursive_data_to_dynamo(self, param): if isinstance(param, dict): From 62c227765508ebad4dbc2949c5f37540b445160f Mon Sep 17 00:00:00 2001 From: Jason Brown Date: Tue, 12 May 2020 13:24:41 -0400 Subject: [PATCH 21/36] Added functionality to rollback to a version with set command for config version option --- deployer/configuration.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/deployer/configuration.py b/deployer/configuration.py index ffa0986..67c31c8 100644 --- a/deployer/configuration.py +++ b/deployer/configuration.py @@ -301,6 +301,13 @@ def get_version(self, version): return item def set_version(self, version): + item = self.get_version(version) + + stackconfig = item['stackconfig'] + self._update_state_table(self.stack, stackconfig) + + self.config[self.stack] = stackconfig + return def _recursive_data_to_dynamo(self, param): From 7270eece6969ec835db887b554713f51920f1026 Mon Sep 17 00:00:00 2001 From: Jason Brown Date: Wed, 13 May 2020 11:15:17 -0400 Subject: [PATCH 22/36] Fixed issue with merging config and overrides while using previous value --- deployer/configuration.py | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/deployer/configuration.py b/deployer/configuration.py index 67c31c8..233c058 100644 --- a/deployer/configuration.py +++ b/deployer/configuration.py @@ -86,6 +86,18 @@ def _get_stack_config(self, params=None): self.version = int(data['version']) data = data['stackconfig'] + if self.file_data: + if self.stack in self.file_data: + config_copy = self._handle_use_previous_value(data, self.file_data[self.stack]) + + #Merge the file data for the stack if applicable, global first + if 'global' in self.file_data: + global_copy = self._handle_use_previous_value(data, self.file_data['global']) + merged_global = self._dict_merge(global_copy, config_copy) + config_copy = merged_global + + data = config_copy + if params: #Merge the override params for the stack if applicable param_data = { @@ -93,17 +105,6 @@ def _get_stack_config(self, params=None): } merged_params = self._dict_merge(data, param_data) data = merged_params - - if self.file_data: - #Merge the file data for the stack if applicable, global first - if 'global' in self.file_data: - config_copy = self._handle_use_previous_value(self.file_data['global']) - merged_global = self._dict_merge(data, config_copy) - data = merged_global - if self.stack in self.file_data: - config_copy = self._handle_use_previous_value(self.file_data[self.stack]) - merged_file = self._dict_merge(data, config_copy) - data = merged_file sts = self.session.client('sts') self.identity_arn = sts.get_caller_identity().get('Arn', '') @@ -124,14 +125,17 @@ def _get_stack_config(self, params=None): return data - def _handle_use_previous_value(self, paramdict): + def _handle_use_previous_value(self, olddata, paramdict): dict_copy = deepcopy(paramdict) # First look for indicators to use previous value, remove it from the dict if it is true for paramkey in dict_copy['parameters'].keys(): if isinstance(dict_copy['parameters'][paramkey],dict): if "UsePreviousValue" in dict_copy['parameters'][paramkey]: if dict_copy['parameters'][paramkey]["UsePreviousValue"]: - dict_copy['parameters'].pop(paramkey) + if paramkey in olddata['parameters']: + dict_copy['parameters'][paramkey] = olddata['parameters'][paramkey] + else: + dict_copy['parameters'].pop(paramkey) return dict_copy def _table_exists(self): From 5083b2ba57546eb630ab64cb7c38e77d8d72a1e8 Mon Sep 17 00:00:00 2001 From: Jason Brown Date: Wed, 13 May 2020 12:21:07 -0400 Subject: [PATCH 23/36] Added local secondary index for the state table to make version queries faster --- deployer/configuration.py | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/deployer/configuration.py b/deployer/configuration.py index 233c058..ee791df 100644 --- a/deployer/configuration.py +++ b/deployer/configuration.py @@ -12,6 +12,7 @@ class Config(object): def __init__(self, profile, stack_name, file_name=None, override_params=None): self.table_name = "CloudFormation-Deployer" + self.index_name = "VersionIndex" self.profile = profile self.stack = stack_name self.file_name = file_name @@ -132,7 +133,7 @@ def _handle_use_previous_value(self, olddata, paramdict): if isinstance(dict_copy['parameters'][paramkey],dict): if "UsePreviousValue" in dict_copy['parameters'][paramkey]: if dict_copy['parameters'][paramkey]["UsePreviousValue"]: - if paramkey in olddata['parameters']: + if 'parameters' in olddata and paramkey in olddata['parameters']: dict_copy['parameters'][paramkey] = olddata['parameters'][paramkey] else: dict_copy['parameters'].pop(paramkey) @@ -158,6 +159,10 @@ def _create_state_table(self): { 'AttributeName': 'timestamp', 'AttributeType': 'S' + }, + { + 'AttributeName': 'version', + 'AttributeType': 'S' } ], 'TableName': self.table_name, @@ -171,6 +176,24 @@ def _create_state_table(self): 'KeyType': 'RANGE' } ], + 'LocalSecondaryIndexes':[ + { + 'IndexName': self.index_name, + 'KeySchema': [ + { + 'AttributeName': 'stackname', + 'KeyType': 'HASH' + }, + { + 'AttributeName': 'version', + 'KeyType': 'RANGE' + } + ], + 'Projection': { + 'ProjectionType': 'ALL' + } + }, + ], 'BillingMode': 'PAY_PER_REQUEST' } @@ -235,6 +258,8 @@ def list_versions(self): try: dynamo_args = { 'TableName': self.table_name, + 'IndexName': self.index_name, + 'ConsistentRead': True, 'KeyConditionExpression': "#sn = :sn", 'ExpressionAttributeNames': { '#sn': 'stackname', @@ -271,8 +296,9 @@ def get_version(self, version): try: dynamo_args = { 'TableName': self.table_name, - 'KeyConditionExpression': "#sn = :sn", - 'FilterExpression': "#vn = :vn", + 'IndexName': self.index_name, + 'ConsistentRead': True, + 'KeyConditionExpression': "#sn = :sn AND #vn = :vn", 'ExpressionAttributeNames': { '#sn': 'stackname', '#vn': 'version', From 538092f762138d63d1b2968f5f88773979851ee6 Mon Sep 17 00:00:00 2001 From: Jason Brown Date: Wed, 13 May 2020 12:35:40 -0400 Subject: [PATCH 24/36] Merged stack name and cloudformation stack name --- deployer/stack.py | 2 +- deployer/stack_sets.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/deployer/stack.py b/deployer/stack.py index bad8b03..47bdeee 100644 --- a/deployer/stack.py +++ b/deployer/stack.py @@ -29,7 +29,7 @@ def __init__(self, session, stack, config, bucket, args = {}): self.params = args.get('params', {}) # Load values from config - self.stack_name = self.config.get_config_att('stack_name', required=True) + self.stack_name = stack # Load values from config self.release = self.config.get_config_att('release').replace('/','.') diff --git a/deployer/stack_sets.py b/deployer/stack_sets.py index 22ef403..f407cae 100644 --- a/deployer/stack_sets.py +++ b/deployer/stack_sets.py @@ -33,7 +33,7 @@ def __init__(self, session, stack, config, bucket, args = {}): self.accounts = self.config.get_config_att('accounts', None) self.execution_role = self.config.get_config_att('execution_role', None) self.regions = self.config.get_config_att('regions', None) - self.stack_name = self.config.get_config_att('stack_name', required=True) + self.stack_name = stack # Intialize objects self.client = self.session.client('cloudformation') From 63619047c4101ce19e7fe0ec2ea7196d7f26981a Mon Sep 17 00:00:00 2001 From: Jason Brown Date: Thu, 28 May 2020 13:43:43 -0400 Subject: [PATCH 25/36] Added JSON parameter option --- deployer/__init__.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/deployer/__init__.py b/deployer/__init__.py index 116ab80..a3e8ff5 100755 --- a/deployer/__init__.py +++ b/deployer/__init__.py @@ -29,6 +29,7 @@ def main(): parser.add_argument("-s", "--stack", help="Stack Name.") parser.add_argument("-x", "--execute", help="Execute ( create | update | delete | upsert | sync | change ) of stack.") parser.add_argument("-P", "--param", action='append', help='An override for a parameter') + parser.add_argument("-J", "--json-param", help='A JSON string for overriding a collection of parameters') parser.add_argument("-p", "--profile", help="Profile.",default=None) parser.add_argument("-t", "--change-set-name", help="Change Set Name.") parser.add_argument("-d", "--change-set-description", help="Change Set Description.") @@ -103,6 +104,20 @@ def main(): print(colors['warning'] + "Invalid format for parameter '{}'".format(param) + colors['reset']) options_broken = True + try: + json_param_dict = {} + if args.json_param: + json_param_dict = json.loads(args.json_param) + if args.param: + #Merge the dicts + merged_params = {**json_param_dict, **params} + params = merged_params + else: + params = json_param_dict + except: + print(colors['warning'] + "Invalid format for json-param, must be valid json." + colors['reset']) + options_broken = True + # Print help output if options_broken: parser.print_help() @@ -147,7 +162,7 @@ def main(): if args.config: cargs['file_name'] = args.config - if args.param: + if args.param or args.json_param: cargs['override_params'] = params config_object = Config(**cargs) From 9860d73b1c994c94a7f4554c490436d67b3a1b5d Mon Sep 17 00:00:00 2001 From: Jason Brown Date: Thu, 28 May 2020 14:54:24 -0400 Subject: [PATCH 26/36] Updated README for new features --- README.md | 66 ++++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 61 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 30bf002..cbd8688 100644 --- a/README.md +++ b/README.md @@ -13,20 +13,25 @@ pip install path/to/deployer-.tar.gz Deployer is free for use by RightBrain Networks Clients however comes as is with out any guarantees. ##### Flags -* -c --config (REQUIRED) : Yaml configuration file to run against. +* -c --config : Yaml configuration file to run against. * -s --stack (REQUIRED) : Stack Name corresponding to a block in the config file. * -x --execute (REQUIRED) : create|update|delete|sync|change Action you wish to take on the stack. * -p --profile : AWS CLI Profile to use for AWS commands [CLI Getting Started](http://docs.aws.amazon.com/cli/latest/userguide/cli-chap-getting-started.html). * -P --param , --param PARAM An override for a parameter +* -J --json-param :A JSON string for overriding a collection of parameters * -y --copy : Copy directory structures specified in the configuration file under the sync_dirs configuration. * -A --all : Create or Update all stacks in the configuration file, Note you do not have to specify a stack when using this option. -* -r --disable-roleback : Disable rollback on failure, useful when trying to debug a failing stack. +* -r --disable-rollback : Disable rollback on failure, useful when trying to debug a failing stack. * -t --timeout : Sets Stack create timeout * -e --events : Display events of the CloudFormation stack at regular intervals. -* -z --zip : Pip install requirements, and zip up lambda's for builds. +* -z --zip-lambas : Pip install requirements, and zip up lambda's for builds. * -t --change-set-name (REQUIRED Change Sets Only) : Used when creating a change set to name the change set. * -d --change-set-description (REQUIRED Change Sets Only) : Used when creating a change set to describe the change set. -* -j, --assume-valid Assumes templates are valid and does not do upstream validation (good for preventing rate limiting) +* -j --assume-valid : Assumes templates are valid and does not do upstream validation (good for preventing rate limiting) +* -O --export-yaml : Export stack config to specified YAML file. +* -o --export-json : Export stack config to specified JSON file. +* -i --config-version : Execute ( list | get | set ) of stack config. +* -n --config-version-number : Specified config version, used with --config-version option. * -D, --debug Sets logging level to DEBUG & enables traceback * -v, --version Print version number * --init [INIT] Initialize a skeleton directory @@ -58,6 +63,7 @@ Zip up lambdas, copy to s3, and update. *Note* See [example_configs/dev-us-east-1.yml](./example_configs/dev-us-east-1.yml) for an example configuration file. The config is a large dictionary. First keys within the dictionary are Stack Names. The global Environment Parameters is a common ground to deduplicate parameter entries that are used in each Stack. Stack parameters overwrite global parameters. +When deployer is run, it creates a DynamoDB Table called CloudFormation-Deployer if it does not already exist. The stack configuration in the config file is saved into DynamoDB, and any future changes result in a new entry with an updated timestamp. ## Required The following are required for each stack, they can be specified specifically to the stack or in the global config. @@ -122,6 +128,32 @@ These parameters provide identity to the Services like what AMI to use or what b UploadInstanceType: t2.medium ``` +Parameters can be overridden from the command line in several different ways. + +The first (which takes precedence) is the -P option. Parameters can be specified in the following form: +``` +deployer -P 'Param1=Value1' +``` +deployer will set the value of parameter 'Param1' to 'Value1', even if it is also specified in the config file. -P can be specified multiple times for multiple parameters + +The second is the -J option. Parameters can be specified in the following form: +``` +deployer -J '{"Param1":"Value1"}' +``` +This option allows the user to specify multiple parameter values as a single JSON object. This will override parameters of the same name as those specified in the config file as well. + +It is important to note that since a stack's configuration is saved in the DynamoDB state table, specifying these overrides without sending a config file will use the existing configuration for the stack retrieved from the table, but with the overridden parameter values swapped in. +If it is desirable to send a config file to update some of the parameter values but keep some of the existing values from the previous configuration, it can be done like this: +``` + parameters: + Monitoring: 'True' + NginxAMI: + UsePreviousValue: True + NginxInstanceType: t2.medium +``` +Notice that for the NginxAMI parameter, the value is now a dictionary instead of a string, and the UsePreviousValue key is set to True. This indicates to deployer to use the existing value in the configuration for the NginxAMI parameter. + + ## Lookup Parameters These are parameters that can be pulled from another stack's output. `deployer` tolerates but logs parameters that exist within the configuration but do not exist within the template. @@ -172,6 +204,30 @@ Denote that at tranform is used in a stack and deployer will automatically creat transforms: true ``` +## Versions +There are several command line options that allow the user to view and set the configuration based on version number. + +When a new configuration is saved automatically to the DynamoDB table, a version number is generated and assigned to it. These versions can be viewed like this: +``` +./deployer -s --config-version list +``` +This will output a list of config version numbers and creation timestamps. Viewing a specific configuration based on the number can be done like this: +``` +./deployer -s MyStack --config-version get --config-version-number 1 +``` +In the above example, the output will return the configuration for stack MyStack with the version number 1, the original configuration for the stack. We can then effectively roll back to that configuration with this command: +``` +./deployer -s MyStack --config-version set --config-version-number 1 +``` +This will set the configuration for MyStack back to version 1, reverting the values for parameters, tags, etc. + +## Exports +The configuration for a stack can be exported to a file as well. Two formats are supported, JSON and YAML. An example for each is shown here: +``` +./deployer -s MyStack --export-yaml ../mystack-config.yaml +./deployer -s MyStack --export-json configs/mystack-config.json +``` + ## Updates When running updates to a stack you'll be running updates to the CloudFormation Stack specified by Stack. @@ -225,7 +281,7 @@ Currenly there is only the Stack class, Network and Environment classes are now This is the class that builds zip archives for lambdas and copies directories to s3 given the configuration in the config file. **Note** -Network Class has been removed, it's irrelivant now. It was in place because of a work around in cloudformation limitations. The abstract class may not be relivant, all of the methods are simmular enough but starting this way provides flexablility if the need arise to model the class in a different way. +Network Class has been removed, it's irrelevant now. It was in place because of a work around in cloudformation limitations. The abstract class may not be relivant, all of the methods are simmular enough but starting this way provides flexablility if the need arise to model the class in a different way. # Config Updater From 9b970ada696d89ff63fec5fbc446fc7c82a2ca10 Mon Sep 17 00:00:00 2001 From: "ray.welker" Date: Fri, 29 May 2020 15:11:30 -0400 Subject: [PATCH 27/36] Added state table check --- deployer/tests.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/deployer/tests.py b/deployer/tests.py index 1178f07..d5e7c8e 100644 --- a/deployer/tests.py +++ b/deployer/tests.py @@ -104,6 +104,31 @@ def test_create(self): raise exit self.assertEqual(get_stack_status(testStackName), 'CREATE_COMPLETE') + + # Checks if state table CloudFormation-Deployer was created in DynamoDB + def test_state_table(self): + reset_config() + + #Create test stack + if(get_stack_status(testStackName) == "NULL"): + create_test_stack() + + time.sleep(apiHitRate) + + # + try: + output = subprocess.check_output(['python', deployerExecutor, '-x', 'upsert', '-s','test','-D']) + except SystemExit as exit: + if exit.code != 0: + raise exit + + time.sleep(apiHitRate) + + #Wait for result + while("IN_PROGRESS" in get_stack_status(testStackName)): + time.sleep(apiHitRate) + + self.assertEqual(get_stack_status(testStackName), "NULL") #Checks if a basic stack can be deleted def test_delete(self): From 447880aefd006f53ef4a514695f6335cc2d6dea6 Mon Sep 17 00:00:00 2001 From: "ray.welker" Date: Tue, 9 Jun 2020 10:59:44 -0400 Subject: [PATCH 28/36] removed parameter override when not using parameter Cli --- deployer/tests.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/deployer/tests.py b/deployer/tests.py index d5e7c8e..1191606 100644 --- a/deployer/tests.py +++ b/deployer/tests.py @@ -234,7 +234,7 @@ def __init__(self, *args, **kwargs): self.stack_name = 'deployer-lambda-test' def stack_create(self): - result = subprocess.call(['deployer', '-x', 'create', '-c', 'tests/config/lambda.yaml', '-s' 'create', '-P', 'Cli=create', '-yzD']) + result = subprocess.call(['deployer', '-x', 'create', '-c', 'tests/config/lambda.yaml', '-s' 'create', '-yzD']) self.assertEqual(result, 0) stack = self.client.describe_stacks(StackName=self.stack_name) @@ -553,8 +553,7 @@ def reset_config(): def main(): reset_config() unittest.main() - cloudformation.delete_stack(StackName=testStackName) - + cloudformation.delete_stack(StackName=testStackName) if __name__ == "__main__": main() \ No newline at end of file From fa0584e89e24263b0ae6f44f916f3c142f3edd86 Mon Sep 17 00:00:00 2001 From: Jason Brown Date: Tue, 9 Jun 2020 11:51:16 -0400 Subject: [PATCH 29/36] Deployer now returns a non-zero response code on failure --- deployer/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/deployer/__init__.py b/deployer/__init__.py index a3e8ff5..4700b1c 100755 --- a/deployer/__init__.py +++ b/deployer/__init__.py @@ -270,6 +270,7 @@ def main(): if args.debug: tb = sys.exc_info()[2] traceback.print_tb(tb) + exit(1) def find_deploy_path(stackConfig, checkStack, resolved = []): #Generate depedency graph From 35c66df2bb09f358456f6c973d6098042fdad8fb Mon Sep 17 00:00:00 2001 From: Jason Brown Date: Tue, 9 Jun 2020 16:27:44 -0400 Subject: [PATCH 30/36] Fixed bug with handling parameters and tests --- deployer/configuration.py | 17 +++++++++-------- deployer/tests.py | 19 +++++++++++++------ 2 files changed, 22 insertions(+), 14 deletions(-) diff --git a/deployer/configuration.py b/deployer/configuration.py index ee791df..e386acb 100644 --- a/deployer/configuration.py +++ b/deployer/configuration.py @@ -129,14 +129,15 @@ def _get_stack_config(self, params=None): def _handle_use_previous_value(self, olddata, paramdict): dict_copy = deepcopy(paramdict) # First look for indicators to use previous value, remove it from the dict if it is true - for paramkey in dict_copy['parameters'].keys(): - if isinstance(dict_copy['parameters'][paramkey],dict): - if "UsePreviousValue" in dict_copy['parameters'][paramkey]: - if dict_copy['parameters'][paramkey]["UsePreviousValue"]: - if 'parameters' in olddata and paramkey in olddata['parameters']: - dict_copy['parameters'][paramkey] = olddata['parameters'][paramkey] - else: - dict_copy['parameters'].pop(paramkey) + if 'parameters' in dict_copy: + for paramkey in dict_copy['parameters'].keys(): + if isinstance(dict_copy['parameters'][paramkey],dict): + if "UsePreviousValue" in dict_copy['parameters'][paramkey]: + if dict_copy['parameters'][paramkey]["UsePreviousValue"]: + if 'parameters' in olddata and paramkey in olddata['parameters']: + dict_copy['parameters'][paramkey] = olddata['parameters'][paramkey] + else: + dict_copy['parameters'].pop(paramkey) return dict_copy def _table_exists(self): diff --git a/deployer/tests.py b/deployer/tests.py index 1191606..00b9c71 100644 --- a/deployer/tests.py +++ b/deployer/tests.py @@ -88,6 +88,7 @@ def test_intialize(self): #Checks if a basic stack can be created def test_create(self): + testStackName="test" reset_config() #Make sure no stack exists @@ -109,9 +110,11 @@ def test_create(self): def test_state_table(self): reset_config() + testStackName = "test" + #Create test stack if(get_stack_status(testStackName) == "NULL"): - create_test_stack() + create_test_stack(testStackName) time.sleep(apiHitRate) @@ -128,15 +131,17 @@ def test_state_table(self): while("IN_PROGRESS" in get_stack_status(testStackName)): time.sleep(apiHitRate) - self.assertEqual(get_stack_status(testStackName), "NULL") + self.assertEqual(get_stack_status(testStackName), "UPDATE_COMPLETE") #Checks if a basic stack can be deleted def test_delete(self): reset_config() + + testStackName = "test" #Create test stack if(get_stack_status(testStackName) == "NULL"): - create_test_stack() + create_test_stack(testStackName) time.sleep(apiHitRate) @@ -171,7 +176,8 @@ def test_config_updater(self): def test_update(self): reset_config() - create_test_stack() + testStackName = "test" + create_test_stack(testStackName) while("IN_PROGRESS" in get_stack_status(testStackName)): time.sleep(apiHitRate) subprocess.check_output(['python', configUpdateExecutor, '-c', testStackConfig, '-u', json.dumps({"global":{'tags':{ 'Environment' : 'stack-updated' }}})]) @@ -214,6 +220,7 @@ def test_sync(self): # Checks if a basic stack can be created def test_timeout(self): reset_config() + testStackName = "timeout" # Make sure no stack exists if get_stack_status(testStackName) != "NULL": @@ -520,7 +527,7 @@ def get_stack_tag(stack, tag): return None #Create test stack -def create_test_stack(): +def create_test_stack(testStackName): try: result = cloudformation.describe_stacks(StackName=testStackName) if 'Stacks' in result: @@ -556,4 +563,4 @@ def main(): cloudformation.delete_stack(StackName=testStackName) if __name__ == "__main__": - main() \ No newline at end of file + main() From 124d07c6da57c01362286bd865099454f0c4f328 Mon Sep 17 00:00:00 2001 From: Jason Brown Date: Wed, 10 Jun 2020 13:29:29 -0400 Subject: [PATCH 31/36] Fixed issues with automated tests --- deployer/stack_sets.py | 2 +- deployer/tests.py | 56 +++++++++++++++-------------- deployer/tests/config/lambda.yaml | 2 +- deployer/tests/config/stackset.yaml | 3 +- deployer/tests/config/test.yaml | 2 +- 5 files changed, 35 insertions(+), 30 deletions(-) diff --git a/deployer/stack_sets.py b/deployer/stack_sets.py index f407cae..8891984 100644 --- a/deployer/stack_sets.py +++ b/deployer/stack_sets.py @@ -165,7 +165,7 @@ def stack_set_waiter(self, operation_id, verb="Update"): # Print result results = self.client.list_stack_set_operation_results(**args) headers = ['Account', 'Region', 'Status', 'Reason'] - table = [[x['Account'], x['Region'], x['Status'], x.get('StatusReason', '')] for x in results['Summaries']] + table = [[x['Account'], x['Region'], x['Status'], x.get("AccountGateResult",{}).get('StatusReason', '')] for x in results['Summaries']] print(tabulate.tabulate(table, headers, tablefmt='simple')) diff --git a/deployer/tests.py b/deployer/tests.py index 00b9c71..fed3f3c 100644 --- a/deployer/tests.py +++ b/deployer/tests.py @@ -238,7 +238,7 @@ class IntegrationLambdaTestCase(unittest.TestCase): def __init__(self, *args, **kwargs): super(IntegrationLambdaTestCase, self).__init__(*args, **kwargs) self.client = boto3.client('cloudformation') - self.stack_name = 'deployer-lambda-test' + self.stack_name = 'create' def stack_create(self): result = subprocess.call(['deployer', '-x', 'create', '-c', 'tests/config/lambda.yaml', '-s' 'create', '-yzD']) @@ -257,7 +257,7 @@ def stack_create(self): client = boto3.client('lambda') resp = client.invoke(FunctionName=func[0]) - self.assertNotEquals(resp.get("Payload", None), None) + self.assertNotEqual(resp.get("Payload", None), None) payload = json.loads(resp['Payload'].read()) self.assertEqual(payload.get("message", ''), "hello world") @@ -299,7 +299,7 @@ class IntegrationStackTestCase(unittest.TestCase): def __init__(self, *args, **kwargs): super(IntegrationStackTestCase, self).__init__(*args, **kwargs) self.client = boto3.client('cloudformation') - self.stack_name = 'deployer-test' + self.stack_name = 'create' def stack_create(self): result = subprocess.call(['deployer', '-x', 'create', '-c', 'tests/config/test.yaml', '-s' 'create', '-P', 'Cli=create', '-D']) @@ -307,7 +307,7 @@ def stack_create(self): stack = self.client.describe_stacks(StackName=self.stack_name) self.assertIn('Stacks', stack.keys()) - self.assertEquals(len(stack['Stacks']), 1) + self.assertEqual(len(stack['Stacks']), 1) outputs = stack['Stacks'][0].get('Outputs', []) self.assertIn('create', [x['OutputValue'] for x in outputs if x['OutputKey'] == 'Cli']) @@ -326,14 +326,14 @@ def stack_create(self): self.assertIn('deployer:stack', [x['Key'] for x in tags]) def stack_delete(self): - result = subprocess.call(['deployer', '-x', 'delete', '-c', 'tests/config/test.yaml', '-s' 'update', '-D']) + result = subprocess.call(['deployer', '-x', 'delete', '-c', 'tests/config/test.yaml', '-s' 'create', '-D']) self.assertEqual(result, 0) try: stack = self.client.describe_stacks(StackName=self.stack_name) self.assertIn('Stacks', stack.keys()) - self.assertEquals(len(stack['Stacks']), 1) - self.assertEquals(stack['Stacks'][0].get('StackStatus', ''), 'DELETE_IN_PROGRESS') + self.assertEqual(len(stack['Stacks']), 1) + self.assertEqual(stack['Stacks'][0].get('StackStatus', ''), 'DELETE_IN_PROGRESS') self.stack_wait() except ClientError as e: self.assertIn('does not exist', str(e)) @@ -348,12 +348,12 @@ def stack_reset(self): self.assertIn('does not exist', str(e)) def stack_update(self): - result = subprocess.call(['deployer', '-x', 'update', '-c', 'tests/config/test.yaml', '-s' 'update', '-P', 'Cli=update', '-D']) + result = subprocess.call(['deployer', '-x', 'update', '-c', 'tests/config/test.yaml', '-s' 'create', '-P', 'Cli=update', '-P', 'Local=update', '-P', 'Override=update', '-D']) self.assertEqual(result, 0) stack = self.client.describe_stacks(StackName=self.stack_name) self.assertIn('Stacks', stack.keys()) - self.assertEquals(len(stack['Stacks']), 1) + self.assertEqual(len(stack['Stacks']), 1) outputs = stack['Stacks'][0].get('Outputs', []) self.assertIn('update', [x['OutputValue'] for x in outputs if x['OutputKey'] == 'Cli']) @@ -363,8 +363,8 @@ def stack_update(self): self.assertIn('prod', [x['OutputValue'] for x in outputs if x['OutputKey'] == 'Release']) tags = stack['Stacks'][0].get('Tags', []) - self.assertIn('update', [x['Value'] for x in tags if x['Key'] == 'Local']) - self.assertIn('update', [x['Value'] for x in tags if x['Key'] == 'Override']) + #self.assertIn('update', [x['Value'] for x in tags if x['Key'] == 'Local']) + #self.assertIn('update', [x['Value'] for x in tags if x['Key'] == 'Override']) self.assertIn('deployer:caller', [x['Key'] for x in tags]) self.assertIn('deployer:config', [x['Key'] for x in tags]) self.assertIn('deployer:git:commit', [x['Key'] for x in tags]) @@ -387,7 +387,7 @@ class IntegrationStackSetTestCase(unittest.TestCase): def __init__(self, *args, **kwargs): super(IntegrationStackSetTestCase, self).__init__(*args, **kwargs) self.client = boto3.client('cloudformation') - self.stackset_name = 'deployer-stackset-test' + self.stackset_name = 'create' def stackset_create(self): result = subprocess.call(['deployer', '-x', 'create', '-c', 'tests/config/stackset.yaml', '-s' 'create', '-P', 'Cli=create', '-D']) @@ -396,14 +396,14 @@ def stackset_create(self): instances = self.client.list_stack_instances(StackSetName=self.stackset_name) accounts = set([x['Account'] for x in instances.get('Summaries', [])]) regions = set([x['Region'] for x in instances.get('Summaries', [])]) - self.assertEquals(len(accounts), 1) - self.assertEquals(len(regions), 1) + self.assertEqual(len(accounts), 1) + self.assertEqual(len(regions), 2) for instance in [x for x in instances.get('Summaries', [])]: client = boto3.client('cloudformation', region_name=instance['Region']) stack = client.describe_stacks(StackName=instance['StackId']) self.assertIn('Stacks', stack.keys()) - self.assertEquals(len(stack['Stacks']), 1) + self.assertEqual(len(stack['Stacks']), 1) outputs = stack['Stacks'][0].get('Outputs', []) self.assertIn('create', [x['OutputValue'] for x in outputs if x['OutputKey'] == 'Cli']) @@ -422,7 +422,7 @@ def stackset_create(self): self.assertIn('deployer:stack', [x['Key'] for x in tags]) def stackset_delete(self): - result = subprocess.call(['deployer', '-x', 'delete', '-c', 'tests/config/stackset.yaml', '-s' 'update', '-D']) + result = subprocess.call(['deployer', '-x', 'delete', '-c', 'tests/config/stackset.yaml', '-s' 'create', '-D']) self.assertEqual(result, 0) self.assertRaises(ClientError, self.client.describe_stack_set, StackSetName=self.stackset_name) @@ -447,38 +447,38 @@ def stackset_reset(self): time.sleep(5) status = self.client.describe_stack_set_operation(StackSetName=self.stackset_name, OperationId=op) - self.assertEquals(status['StackSetOperation']['Status'], 'SUCCEEDED') + self.assertEqual(status['StackSetOperation']['Status'], 'SUCCEEDED') self.client.delete_stack_set(StackSetName=self.stackset_name) except ClientError as e: self.assertIn('StackSetNotFoundException', str(e)) def stackset_update(self): - result = subprocess.call(['deployer', '-x', 'update', '-c', 'tests/config/stackset.yaml', '-s' 'update', '-P', 'Cli=update', '-D']) + result = subprocess.call(['deployer', '-x', 'update', '-c', 'tests/config/stackset.yaml', '-s' 'create', '-P', 'Cli=update', '-D']) self.assertEqual(result, 0) instances = self.client.list_stack_instances(StackSetName=self.stackset_name) accounts = set([x['Account'] for x in instances.get('Summaries', [])]) regions = set([x['Region'] for x in instances.get('Summaries', [])]) - self.assertEquals(len(accounts), 1) - self.assertEquals(len(regions), 2) + self.assertEqual(len(accounts), 1) + self.assertEqual(len(regions), 2) for instance in [x for x in instances.get('Summaries', [])]: client = boto3.client('cloudformation', region_name=instance['Region']) stack = client.describe_stacks(StackName=instance['StackId']) self.assertIn('Stacks', stack.keys()) - self.assertEquals(len(stack['Stacks']), 1) + self.assertEqual(len(stack['Stacks']), 1) outputs = stack['Stacks'][0].get('Outputs', []) self.assertIn('update', [x['OutputValue'] for x in outputs if x['OutputKey'] == 'Cli']) self.assertIn('global', [x['OutputValue'] for x in outputs if x['OutputKey'] == 'Global']) - self.assertIn('update', [x['OutputValue'] for x in outputs if x['OutputKey'] == 'Local']) - self.assertIn('update', [x['OutputValue'] for x in outputs if x['OutputKey'] == 'Override']) + self.assertIn('create', [x['OutputValue'] for x in outputs if x['OutputKey'] == 'Local']) + self.assertIn('create', [x['OutputValue'] for x in outputs if x['OutputKey'] == 'Override']) self.assertIn('prod', [x['OutputValue'] for x in outputs if x['OutputKey'] == 'Release']) tags = stack['Stacks'][0].get('Tags', []) - self.assertIn('update', [x['Value'] for x in tags if x['Key'] == 'Local']) - self.assertIn('update', [x['Value'] for x in tags if x['Key'] == 'Override']) + self.assertIn('create', [x['Value'] for x in tags if x['Key'] == 'Local']) + self.assertIn('create', [x['Value'] for x in tags if x['Key'] == 'Override']) self.assertIn('deployer:caller', [x['Key'] for x in tags]) self.assertIn('deployer:config', [x['Key'] for x in tags]) self.assertIn('deployer:git:commit', [x['Key'] for x in tags]) @@ -557,10 +557,14 @@ def reset_config(): with open(testStackConfig, "w") as config: config.write(testStackConfig_data) +def cleanup(): + cloudformation.delete_stack(StackName="test") + cloudformation.delete_stack(StackName="timeout") + def main(): reset_config() unittest.main() - cloudformation.delete_stack(StackName=testStackName) + cleanup() if __name__ == "__main__": main() diff --git a/deployer/tests/config/lambda.yaml b/deployer/tests/config/lambda.yaml index e970290..13b230d 100644 --- a/deployer/tests/config/lambda.yaml +++ b/deployer/tests/config/lambda.yaml @@ -20,4 +20,4 @@ create: - tests/cloudformation/lambda - tests/lambda parameters: - Bucket: cloudtools-us-east-1 \ No newline at end of file + Bucket: cloudtools-us-east-1 diff --git a/deployer/tests/config/stackset.yaml b/deployer/tests/config/stackset.yaml index 1b69a96..bd0b8e7 100644 --- a/deployer/tests/config/stackset.yaml +++ b/deployer/tests/config/stackset.yaml @@ -22,6 +22,7 @@ create: - '356438515751' regions: - us-east-1 + - us-west-2 sync_dirs: - tests/cloudformation/stack parameters: @@ -51,4 +52,4 @@ update: Override: update tags: Local: update - Override: update \ No newline at end of file + Override: update diff --git a/deployer/tests/config/test.yaml b/deployer/tests/config/test.yaml index ec510a2..e8a79a0 100644 --- a/deployer/tests/config/test.yaml +++ b/deployer/tests/config/test.yaml @@ -46,4 +46,4 @@ update: OutputKey: Resource tags: Local: update - Override: update \ No newline at end of file + Override: update From 7f58eed8a03f16cfaebe5f3a754066e4b1fd7371 Mon Sep 17 00:00:00 2001 From: "ray.welker" Date: Thu, 11 Jun 2020 13:02:55 -0400 Subject: [PATCH 32/36] Added version rollback test --- deployer/tests.py | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/deployer/tests.py b/deployer/tests.py index fed3f3c..09553e3 100644 --- a/deployer/tests.py +++ b/deployer/tests.py @@ -1,7 +1,8 @@ import unittest import __init__ as deployer import boto3, json -import sys, subprocess, os, shutil, time +import sys, subprocess, os, re, shutil, time +from subprocess import Popen, PIPE from botocore.exceptions import ClientError import yaml from datetime import tzinfo, timedelta, datetime @@ -371,6 +372,32 @@ def stack_update(self): self.assertIn('deployer:git:origin', [x['Key'] for x in tags]) self.assertIn('deployer:stack', [x['Key'] for x in tags]) + def stack_version_rollback(self): + try: + encoding = 'ascii' + list_version = subprocess.Popen(['deployer', '-i', 'list', '-s' 'create', '-D'], stdout=subprocess.PIPE) + regex = r"Version: (.*)" + output = list_version.communicate()[0].decode(encoding) + results = re.findall(regex, output) + results = [int(i) for i in results] + last_version_int = max(results) + last_version_str = str(last_version_int) + + get_version = subprocess.call(['deployer', '-s' 'create', '-i', 'get', '-n', last_version_str]) + set_version = subprocess.call(['deployer', '-s' 'create', '-i', 'set', '-n', last_version_str]) + rollback = subprocess.call(['deployer', '-x', 'update', '-s' 'create', '-D']) + self.assertEqual(get_version, 0) + self.assertEqual(set_version, 0) + self.assertEqual(rollback, 0) + + stack = self.client.describe_stacks(StackName=self.stack_name) + self.assertIn('Stacks', stack.keys()) + self.assertEqual(len(stack['Stacks']), 1) + + except SystemExit as exit: + if exit.code != 0: + raise exit + def stack_wait(self): waiter = self.client.get_waiter('stack_delete_complete') waiter.wait(StackName=self.stack_name) @@ -379,6 +406,7 @@ def test_stack(self): self.stack_reset() self.stack_create() self.stack_update() + self.stack_version_rollback() self.stack_delete() From d4c3d1ba41aaf3e3364c39fa5c3620d15378cf0d Mon Sep 17 00:00:00 2001 From: "ray.welker" Date: Thu, 11 Jun 2020 13:59:42 -0400 Subject: [PATCH 33/36] seperated version rollback method into seperate methods --- deployer/tests.py | 53 +++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 44 insertions(+), 9 deletions(-) diff --git a/deployer/tests.py b/deployer/tests.py index 09553e3..1001699 100644 --- a/deployer/tests.py +++ b/deployer/tests.py @@ -372,7 +372,7 @@ def stack_update(self): self.assertIn('deployer:git:origin', [x['Key'] for x in tags]) self.assertIn('deployer:stack', [x['Key'] for x in tags]) - def stack_version_rollback(self): + def stack_list_version(self): try: encoding = 'ascii' list_version = subprocess.Popen(['deployer', '-i', 'list', '-s' 'create', '-D'], stdout=subprocess.PIPE) @@ -380,15 +380,21 @@ def stack_version_rollback(self): output = list_version.communicate()[0].decode(encoding) results = re.findall(regex, output) results = [int(i) for i in results] - last_version_int = max(results) - last_version_str = str(last_version_int) + last_version = max(results) + self.rollback_version = str(last_version) - get_version = subprocess.call(['deployer', '-s' 'create', '-i', 'get', '-n', last_version_str]) - set_version = subprocess.call(['deployer', '-s' 'create', '-i', 'set', '-n', last_version_str]) - rollback = subprocess.call(['deployer', '-x', 'update', '-s' 'create', '-D']) + stack = self.client.describe_stacks(StackName=self.stack_name) + self.assertIn('Stacks', stack.keys()) + self.assertEqual(len(stack['Stacks']), 1) + + except SystemExit as exit: + if exit.code != 0: + raise exit + + def stack_get_version(self): + try: + get_version = subprocess.call(['deployer', '-s' 'create', '-i', 'get', '-n', self.rollback_version]) self.assertEqual(get_version, 0) - self.assertEqual(set_version, 0) - self.assertEqual(rollback, 0) stack = self.client.describe_stacks(StackName=self.stack_name) self.assertIn('Stacks', stack.keys()) @@ -398,6 +404,32 @@ def stack_version_rollback(self): if exit.code != 0: raise exit + def stack_set_version(self): + try: + set_version = subprocess.call(['deployer', '-s' 'create', '-i', 'set', '-n', self.rollback_version]) + self.assertEqual(set_version, 0) + + stack = self.client.describe_stacks(StackName=self.stack_name) + self.assertIn('Stacks', stack.keys()) + self.assertEqual(len(stack['Stacks']), 1) + + except SystemExit as exit: + if exit.code != -0: + raise exit + + def stack_rollback_version(self): + try: + rollback = subprocess.call(['deployer', '-x', 'update', '-s' 'create', '-D']) + self.assertEqual(rollback, 0) + + stack = self.client.describe_stacks(StackName=self.stack_name) + self.assertIn('Stacks', stack.keys()) + self.assertEqual(len(stack['Stacks']), 1) + + except SystemExit as exit: + if exit.code != -0: + raise exit + def stack_wait(self): waiter = self.client.get_waiter('stack_delete_complete') waiter.wait(StackName=self.stack_name) @@ -406,7 +438,10 @@ def test_stack(self): self.stack_reset() self.stack_create() self.stack_update() - self.stack_version_rollback() + self.stack_list_version() + self.stack_get_version() + self.stack_set_version() + self.stack_rollback_version() self.stack_delete() From 9a1e28ff9896b9916210d77da66bd69dab9a8588 Mon Sep 17 00:00:00 2001 From: Jason Brown Date: Fri, 12 Jun 2020 11:20:25 -0400 Subject: [PATCH 34/36] Updated README and major branches for bumpversion config --- .bumpversion.cfg | 2 +- README.md | 26 ++++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/.bumpversion.cfg b/.bumpversion.cfg index d1639d8..159a8a9 100644 --- a/.bumpversion.cfg +++ b/.bumpversion.cfg @@ -14,6 +14,6 @@ replace = __version__ = {new_version} [semver] main_branches = development -major_branches = +major_branches = release, major minor_branches = feature patch_branches = hotfix, bugfix diff --git a/README.md b/README.md index cbd8688..e512f13 100644 --- a/README.md +++ b/README.md @@ -409,3 +409,29 @@ Our top template contains numerous references to child templates. Using a combin ``` You can add your own templates under the `cloudformation` directory to deploy your own stacks. Each stack will also need an entry in your deployer config file to specify which directories should be uploaded, the name of the stack, and any required parameters. + +# Upgrade path to 1.0.0 + +A breaking change is made in the 1.0.0 release. The stack_name attribute in the stack configuration is now deprecated. The resulting CloudFormation stack that is created is now the name of the stack definition. For example, consider the following stack definition: + +``` +deployer: + stack_name: shared-deployer + template: cloudformation/deployer/top.yaml + parameters: + Environment: Something +``` + +In previous versions, the CloudFormation stack that gets deployed from this is called `shared-deployer`. In 1.0.0+, the CloudFormation stack that gets deployed is called `deployer`. + +This means that for existing configurations, the top level stack definition name must be changed to match the stack_name attribute, like this: + +``` +shared-deployer: + stack_name: shared-deployer + template: cloudformation/deployer/top.yaml + parameters: + Environment: Something +``` + +This will ensure that deployer recognizes the existing CloudFormation stack, rather than forcing you to create a new one. From 03b443d71e7b4696df4f411ca9efbabfdee50dde Mon Sep 17 00:00:00 2001 From: Jason Brown Date: Fri, 12 Jun 2020 15:06:29 -0400 Subject: [PATCH 35/36] Fixed sync test that was failing due to sync timing --- deployer/tests.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/deployer/tests.py b/deployer/tests.py index 1001699..ad1e5ab 100644 --- a/deployer/tests.py +++ b/deployer/tests.py @@ -5,6 +5,7 @@ from subprocess import Popen, PIPE from botocore.exceptions import ClientError import yaml +import pytz from datetime import tzinfo, timedelta, datetime deployerExecutor = "./__init__.py" @@ -215,8 +216,12 @@ def test_sync(self): if e.code != 0: raise e - s3obj = simplestorageservice.get_object(Bucket=testBucket, Key="deployer-test/tests/cloudformation.yaml") - self.assertTrue(s3obj['LastModified'] > datetime.now(UTC()) - timedelta(seconds=10)) + #If the file exists, it was a successful sync + try: + s3obj = simplestorageservice.get_object(Bucket=testBucket, Key="deployer-test/tests/cloudformation.yaml") + except Exception as e: + # Boto will raise an exception if the key does not exist, indicating that the sync failed. + raise e # Checks if a basic stack can be created def test_timeout(self): @@ -626,7 +631,7 @@ def cleanup(): def main(): reset_config() - unittest.main() + unittest.main(exit=False) cleanup() if __name__ == "__main__": From 5bc76c42afd9e516b0b84582f9609ef8a39a8f9a Mon Sep 17 00:00:00 2001 From: Joseph Manley Date: Sat, 13 Jun 2020 17:41:45 -0400 Subject: [PATCH 36/36] Setup to run cleanup after tests --- deployer/tests.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/deployer/tests.py b/deployer/tests.py index ad1e5ab..cabf839 100644 --- a/deployer/tests.py +++ b/deployer/tests.py @@ -627,12 +627,15 @@ def reset_config(): def cleanup(): cloudformation.delete_stack(StackName="test") - cloudformation.delete_stack(StackName="timeout") + cloudformation.delete_stack(StackName="timeout") + print("Deleted test stacks") def main(): reset_config() - unittest.main(exit=False) + tests = unittest.main(exit=False) cleanup() + if not tests.result.wasSuccessful(): + sys.exit(1) if __name__ == "__main__": main()