Initial checkin
This commit is contained in:
commit
ea58e60aa9
60
.gitignore
vendored
Normal file
60
.gitignore
vendored
Normal file
@ -0,0 +1,60 @@
|
|||||||
|
# Vim
|
||||||
|
*.swp
|
||||||
|
|
||||||
|
# Byte-compiled / optimized / DLL files
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*$py.class
|
||||||
|
|
||||||
|
# C extensions
|
||||||
|
*.so
|
||||||
|
|
||||||
|
# Distribution / packaging
|
||||||
|
.Python
|
||||||
|
env/
|
||||||
|
build/
|
||||||
|
develop-eggs/
|
||||||
|
dist/
|
||||||
|
downloads/
|
||||||
|
eggs/
|
||||||
|
.eggs/
|
||||||
|
lib64/
|
||||||
|
parts/
|
||||||
|
sdist/
|
||||||
|
var/
|
||||||
|
*.egg-info/
|
||||||
|
.installed.cfg
|
||||||
|
*.egg
|
||||||
|
|
||||||
|
# PyInstaller
|
||||||
|
# Usually these files are written by a python script from a template
|
||||||
|
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||||
|
*.manifest
|
||||||
|
*.spec
|
||||||
|
|
||||||
|
# Installer logs
|
||||||
|
pip-log.txt
|
||||||
|
pip-delete-this-directory.txt
|
||||||
|
|
||||||
|
# Unit test / coverage reports
|
||||||
|
htmlcov/
|
||||||
|
.tox/
|
||||||
|
.coverage
|
||||||
|
.coverage.*
|
||||||
|
.cache
|
||||||
|
.pytest*
|
||||||
|
nosetests.xml
|
||||||
|
coverage.xml
|
||||||
|
*,cover
|
||||||
|
.hypothesis/
|
||||||
|
|
||||||
|
# Translations
|
||||||
|
*.mo
|
||||||
|
*.pot
|
||||||
|
|
||||||
|
# dotenv
|
||||||
|
.env
|
||||||
|
|
||||||
|
# virtualenv
|
||||||
|
venv/
|
||||||
|
ENV/
|
15
cloudbender/__init__.py
Normal file
15
cloudbender/__init__.py
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
__author__ = 'Stefan Reimer'
|
||||||
|
__email__ = 'stefan@zero-downtimet.net'
|
||||||
|
__version__ = '1.1.0'
|
||||||
|
|
||||||
|
|
||||||
|
# Set up logging to ``/dev/null`` like a library is supposed to.
|
||||||
|
# http://docs.python.org/3.3/howto/logging.html#configuring-logging-for-a-library
|
||||||
|
class NullHandler(logging.Handler): # pragma: no cover
|
||||||
|
def emit(self, record):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
logging.getLogger('cloudbender').addHandler(NullHandler())
|
171
cloudbender/cli.py
Normal file
171
cloudbender/cli.py
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
import os
|
||||||
|
import click
|
||||||
|
import functools
|
||||||
|
|
||||||
|
from concurrent.futures import ThreadPoolExecutor, wait
|
||||||
|
|
||||||
|
from . import __version__
|
||||||
|
from .core import CloudBender
|
||||||
|
from .utils import setup_logging
|
||||||
|
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@click.group()
|
||||||
|
@click.version_option(version=__version__, prog_name="CloudBender")
|
||||||
|
@click.option("--debug", is_flag=True, help="Turn on debug logging.")
|
||||||
|
@click.option("--dir", "directory", help="Specify cloudbender project directory.")
|
||||||
|
@click.pass_context
|
||||||
|
def cli(ctx, debug, directory):
|
||||||
|
logger = setup_logging(debug)
|
||||||
|
|
||||||
|
# Read global config
|
||||||
|
cb = CloudBender(directory if directory else os.getcwd())
|
||||||
|
cb.read_config()
|
||||||
|
cb.dump_config()
|
||||||
|
|
||||||
|
ctx.obj['cb'] = cb
|
||||||
|
|
||||||
|
|
||||||
|
@click.command()
|
||||||
|
@click.argument("stack_name")
|
||||||
|
@click.option("--multi", is_flag=True, help="Allow more than one stack to match")
|
||||||
|
@click.pass_context
|
||||||
|
def render(ctx, stack_name, multi):
|
||||||
|
""" Renders template and its parameters """
|
||||||
|
|
||||||
|
stacks = _find_stacks(ctx, stack_name, multi)
|
||||||
|
|
||||||
|
for s in stacks:
|
||||||
|
s.render()
|
||||||
|
s.write_template_file()
|
||||||
|
|
||||||
|
|
||||||
|
@click.command()
|
||||||
|
@click.argument("stack_name")
|
||||||
|
@click.option("--multi", is_flag=True, help="Allow more than one stack to match")
|
||||||
|
@click.pass_context
|
||||||
|
def validate(ctx, stack_name, multi):
|
||||||
|
stacks = _find_stacks(ctx, stack_name, multi)
|
||||||
|
|
||||||
|
for s in stacks:
|
||||||
|
s.validate()
|
||||||
|
|
||||||
|
|
||||||
|
@click.command()
|
||||||
|
@click.argument("stack_name")
|
||||||
|
@click.option("--multi", is_flag=True, help="Allow more than one stack to match")
|
||||||
|
@click.pass_context
|
||||||
|
def provision(ctx, stack_name, multi):
|
||||||
|
""" Creates or updates stacks or stack groups """
|
||||||
|
stacks = _find_stacks(ctx, stack_name, multi)
|
||||||
|
|
||||||
|
for step in sort_stacks(ctx, stacks):
|
||||||
|
if step:
|
||||||
|
with ThreadPoolExecutor(max_workers=len(step)) as group:
|
||||||
|
futures = []
|
||||||
|
for stack in step:
|
||||||
|
status = stack.get_status()
|
||||||
|
if not status:
|
||||||
|
futures.append(group.submit(stack.create))
|
||||||
|
else:
|
||||||
|
futures.append(group.submit(stack.update))
|
||||||
|
wait(futures)
|
||||||
|
|
||||||
|
|
||||||
|
@click.command()
|
||||||
|
@click.argument("stack_name")
|
||||||
|
@click.option("--multi", is_flag=True, help="Allow more than one stack to match")
|
||||||
|
@click.pass_context
|
||||||
|
def delete(ctx, stack_name, multi):
|
||||||
|
""" Deletes stacks or stack groups """
|
||||||
|
stacks = _find_stacks(ctx, stack_name, multi)
|
||||||
|
|
||||||
|
# Reverse steps
|
||||||
|
steps = [s for s in sort_stacks(ctx, stacks)]
|
||||||
|
delete_steps = steps[::-1]
|
||||||
|
for step in delete_steps:
|
||||||
|
if step:
|
||||||
|
with ThreadPoolExecutor(max_workers=len(step)) as group:
|
||||||
|
futures = []
|
||||||
|
for stack in step:
|
||||||
|
if stack.multi_delete:
|
||||||
|
futures.append(group.submit(stack.delete))
|
||||||
|
|
||||||
|
wait(futures)
|
||||||
|
|
||||||
|
|
||||||
|
@click.command()
|
||||||
|
@click.pass_context
|
||||||
|
def clean(ctx):
|
||||||
|
""" Deletes all previously rendered files locally """
|
||||||
|
cb = ctx.obj['cb']
|
||||||
|
cb.clean()
|
||||||
|
|
||||||
|
|
||||||
|
def sort_stacks(ctx, stacks):
|
||||||
|
""" Sort stacks by dependencies """
|
||||||
|
cb = ctx.obj['cb']
|
||||||
|
|
||||||
|
data = {}
|
||||||
|
for s in stacks:
|
||||||
|
# Resolve dependencies
|
||||||
|
deps = []
|
||||||
|
for d in s.dependencies:
|
||||||
|
# For now we assume deps are artifacts so we prepend them with our local profile and region to match stack.id
|
||||||
|
for dep_stack in cb.filter_stacks({'region': s.region, 'profile': s.profile, 'provides': d}):
|
||||||
|
deps.append(dep_stack.id)
|
||||||
|
|
||||||
|
data[s.id] = set(deps)
|
||||||
|
|
||||||
|
for k, v in data.items():
|
||||||
|
v.discard(k) # Ignore self dependencies
|
||||||
|
|
||||||
|
extra_items_in_deps = functools.reduce(set.union, data.values()) - set(data.keys())
|
||||||
|
data.update({item:set() for item in extra_items_in_deps})
|
||||||
|
while True:
|
||||||
|
ordered = set(item for item,dep in data.items() if not dep)
|
||||||
|
if not ordered:
|
||||||
|
break
|
||||||
|
|
||||||
|
# return list of stack objects rather than just names
|
||||||
|
result = []
|
||||||
|
for o in ordered:
|
||||||
|
for s in stacks:
|
||||||
|
if s.id == o: result.append(s)
|
||||||
|
yield result
|
||||||
|
|
||||||
|
data = {item: (dep - ordered) for item,dep in data.items()
|
||||||
|
if item not in ordered}
|
||||||
|
assert not data, "A cyclic dependency exists amongst %r" % data
|
||||||
|
|
||||||
|
|
||||||
|
def _find_stacks(ctx, stack_name,multi):
|
||||||
|
cb = ctx.obj['cb']
|
||||||
|
|
||||||
|
# ALL acts ass config and multi=True
|
||||||
|
if stack_name == "ALL":
|
||||||
|
multi = True
|
||||||
|
stack_name = "config"
|
||||||
|
|
||||||
|
stacks = cb.resolve_stacks(stack_name)
|
||||||
|
|
||||||
|
if not stacks:
|
||||||
|
logger.error('Cannot find stack matching: {}'.format(stack_name))
|
||||||
|
raise click.Abort()
|
||||||
|
|
||||||
|
if not multi and len(stacks) > 1:
|
||||||
|
logger.error('Found more than one ({}) stacks matching name {}: {}. Abort.'.format(len(stacks), stack_name, [s.stackname for s in stacks]))
|
||||||
|
raise click.Abort()
|
||||||
|
|
||||||
|
return stacks
|
||||||
|
|
||||||
|
|
||||||
|
cli.add_command(render)
|
||||||
|
cli.add_command(validate)
|
||||||
|
cli.add_command(provision)
|
||||||
|
cli.add_command(delete)
|
||||||
|
cli.add_command(clean)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
cli(obj={})
|
55
cloudbender/connection.py
Normal file
55
cloudbender/connection.py
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
|
import boto3
|
||||||
|
import botocore.session
|
||||||
|
from botocore import credentials
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class BotoConnection():
|
||||||
|
_sessions= {}
|
||||||
|
_clients = {}
|
||||||
|
|
||||||
|
def __init__(self, profile=None, region=None):
|
||||||
|
self.region = region
|
||||||
|
self.profile = profile
|
||||||
|
|
||||||
|
|
||||||
|
def _get_session(self, profile=None, region=None):
|
||||||
|
if self._sessions.get((profile,region)):
|
||||||
|
return self._sessions[(profile,region)]
|
||||||
|
|
||||||
|
# Construct botocore session with cache
|
||||||
|
# Setup boto to cache STS tokens for MFA
|
||||||
|
# Change the cache path from the default of ~/.aws/boto/cache to the one used by awscli
|
||||||
|
session_vars = {}
|
||||||
|
if profile:
|
||||||
|
session_vars['profile'] = (None,None,profile,None)
|
||||||
|
if region and region != 'global':
|
||||||
|
session_vars['region'] = (None,None,region,None)
|
||||||
|
|
||||||
|
session = botocore.session.Session(session_vars=session_vars)
|
||||||
|
cli_cache = os.path.join(os.path.expanduser('~'),'.aws/cli/cache')
|
||||||
|
session.get_component('credential_provider').get_provider('assume-role').cache = credentials.JSONFileCache(cli_cache)
|
||||||
|
|
||||||
|
self._sessions[(profile,region)] = session
|
||||||
|
|
||||||
|
return session
|
||||||
|
|
||||||
|
|
||||||
|
def _get_client(self, service, profile=None, region=None):
|
||||||
|
if self._clients.get((profile,region,service)):
|
||||||
|
return self._clients[(profile,region,service)]
|
||||||
|
|
||||||
|
session = self._get_session(profile,region)
|
||||||
|
client = boto3.Session(botocore_session=session).client(service)
|
||||||
|
|
||||||
|
self._clients[(profile,region,service)] = client
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
def call(self, service, command, kwargs={}, profile=None, region=None):
|
||||||
|
client = self._get_client(service, profile, region)
|
||||||
|
return getattr(client, command)(**kwargs)
|
120
cloudbender/core.py
Normal file
120
cloudbender/core.py
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
import os
|
||||||
|
import glob
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from .utils import read_yaml_file, ensure_dir
|
||||||
|
from .stack import Stack
|
||||||
|
from .stackgroup import StackGroup
|
||||||
|
from .connection import BotoConnection
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class CloudBender(object):
|
||||||
|
""" Config Class to handle recursive conf/* config tree """
|
||||||
|
def __init__(self, root_path):
|
||||||
|
self.root = root_path
|
||||||
|
self.sg = None
|
||||||
|
self.all_stacks = []
|
||||||
|
self.ctx = {
|
||||||
|
"config_path": os.path.join(self.root, "config"),
|
||||||
|
"template_path": os.path.join(self.root, "cloudformation"),
|
||||||
|
"parameter_path": os.path.join(self.root, "parameters"),
|
||||||
|
"artifact_paths": [os.path.join(self.root, "artifacts")]
|
||||||
|
}
|
||||||
|
|
||||||
|
if not os.path.isdir(self.root):
|
||||||
|
raise "Check '{0}' exists and is a valid project folder.".format(root_path)
|
||||||
|
|
||||||
|
|
||||||
|
def read_config(self):
|
||||||
|
"""Load the <path>/config.yaml, <path>/*.yaml as stacks, sub-folders are child groups """
|
||||||
|
|
||||||
|
# Read top level config.yaml and extract CloudBender CTX
|
||||||
|
_config = read_yaml_file(os.path.join(self.ctx['config_path'], 'config.yaml'))
|
||||||
|
if _config:
|
||||||
|
self.ctx.update(_config.get('CloudBender'))
|
||||||
|
|
||||||
|
# Make sure all paths are abs
|
||||||
|
for k, v in self.ctx.items():
|
||||||
|
if k in ['config_path','template_path','parameter_path','artifact_paths']:
|
||||||
|
if isinstance(v, list):
|
||||||
|
new_list = []
|
||||||
|
for path in v:
|
||||||
|
if not os.path.isabs(path):
|
||||||
|
new_list.append(os.path.normpath(os.path.join(self.root, path)))
|
||||||
|
else:
|
||||||
|
new_list.append(path)
|
||||||
|
self.ctx[k] = new_list
|
||||||
|
|
||||||
|
elif isinstance(v, str):
|
||||||
|
if not os.path.isabs(v):
|
||||||
|
self.ctx[k]=os.path.normpath(os.path.join(self.root, v))
|
||||||
|
|
||||||
|
if k in ['template_path','parameter_path']:
|
||||||
|
ensure_dir(self.ctx[k])
|
||||||
|
|
||||||
|
self.sg = StackGroup(self.ctx['config_path'], self.ctx)
|
||||||
|
self.sg.read_config()
|
||||||
|
|
||||||
|
self.all_stacks = self.sg.get_stacks()
|
||||||
|
|
||||||
|
# If cfn vars config is completely empty set some default for tests to work
|
||||||
|
# if "vars" not in _config:
|
||||||
|
# _config = { "vars": { 'Azs': {'TestAZ': 'Next'}, 'Segments': {'Testnet': 'internet'}, "Mode": "Piped" } }
|
||||||
|
# self.vars.update(_config.get('vars'))
|
||||||
|
|
||||||
|
|
||||||
|
def dump_config(self):
|
||||||
|
logger.debug("<CloudBender: {}>".format(vars(self)))
|
||||||
|
self.sg.dump_config()
|
||||||
|
|
||||||
|
|
||||||
|
def clean(self):
|
||||||
|
for s in self.all_stacks:
|
||||||
|
s.delete_template_file()
|
||||||
|
s.delete_parameter_file()
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_stacks(self, token):
|
||||||
|
stacks = None
|
||||||
|
|
||||||
|
# remove optional leading "config/" to allow bash path expansions
|
||||||
|
if token.startswith("config/"):
|
||||||
|
token = token[7:]
|
||||||
|
|
||||||
|
# If path ends with yaml we look for stacks
|
||||||
|
if token.endswith('.yaml'):
|
||||||
|
stacks = self.sg.get_stacks(token, match_by='path')
|
||||||
|
|
||||||
|
# otherwise assume we look for a group, if we find a group return all stacks below
|
||||||
|
else:
|
||||||
|
# Strip potential trailing slash
|
||||||
|
token = token.rstrip('/')
|
||||||
|
|
||||||
|
sg = self.sg.get_stackgroup(token, match_by='path')
|
||||||
|
if sg:
|
||||||
|
stacks = sg.get_stacks()
|
||||||
|
|
||||||
|
return stacks
|
||||||
|
|
||||||
|
|
||||||
|
def filter_stacks(self, filter_by, stacks=None):
|
||||||
|
# filter_by is a dict { property, value }
|
||||||
|
|
||||||
|
# if no group of stacks provided, look in all available
|
||||||
|
if not stacks:
|
||||||
|
stacks = self.all_stacks
|
||||||
|
|
||||||
|
matching_stacks = []
|
||||||
|
for s in stacks:
|
||||||
|
match = True
|
||||||
|
|
||||||
|
for p,v in filter_by.items():
|
||||||
|
if not (hasattr(s, p) and getattr(s, p) == v):
|
||||||
|
match = False
|
||||||
|
break
|
||||||
|
|
||||||
|
if match:
|
||||||
|
matching_stacks.append(s)
|
||||||
|
|
||||||
|
return matching_stacks
|
179
cloudbender/jinja.py
Normal file
179
cloudbender/jinja.py
Normal file
@ -0,0 +1,179 @@
|
|||||||
|
import os
|
||||||
|
import io
|
||||||
|
import gzip
|
||||||
|
import jinja2
|
||||||
|
import oyaml as yaml
|
||||||
|
import re
|
||||||
|
import base64
|
||||||
|
|
||||||
|
import pyminifier.token_utils
|
||||||
|
import pyminifier.minification
|
||||||
|
import pyminifier.compression
|
||||||
|
import pyminifier.obfuscate
|
||||||
|
import types
|
||||||
|
|
||||||
|
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
@jinja2.contextfunction
|
||||||
|
def get_custom_att(context, att=None, ResourceName="FortyTwo", attributes={}, flush=False, dump=False, dependencies=False):
|
||||||
|
""" Returns the rendered required fragement and also collects all foreign
|
||||||
|
attributes for the specified CustomResource to include them later in
|
||||||
|
the actual CustomResource include property """
|
||||||
|
|
||||||
|
# If flush is set all we do is empty our state dict
|
||||||
|
if flush:
|
||||||
|
attributes.clear()
|
||||||
|
return
|
||||||
|
|
||||||
|
# return all registered attributes
|
||||||
|
if dump:
|
||||||
|
return attributes
|
||||||
|
|
||||||
|
# If dependencies, return all Artifacts this stack depends on, which are the attr of FortyTwo
|
||||||
|
if dependencies:
|
||||||
|
deps = set()
|
||||||
|
if ResourceName in attributes:
|
||||||
|
for att in attributes[ResourceName]:
|
||||||
|
deps.add(att.split('.')[0])
|
||||||
|
|
||||||
|
return list(deps)
|
||||||
|
|
||||||
|
# If call with an attribute, return fragement and register
|
||||||
|
if att:
|
||||||
|
if ResourceName not in attributes:
|
||||||
|
attributes[ResourceName] = set()
|
||||||
|
|
||||||
|
attributes[ResourceName].add(att)
|
||||||
|
config = context.get_all()['_config']
|
||||||
|
if config['cfn']['Mode'] == "FortyTwo":
|
||||||
|
return('{{ "Fn::GetAtt": ["{0}", "{1}"] }}'.format(ResourceName, att))
|
||||||
|
elif config['cfn']['Mode'] == "AWSImport" and ResourceName == "FortyTwo":
|
||||||
|
# AWS only allows - and :, so replace '.' with ":"
|
||||||
|
return('{{ "Fn::ImportValue": {{ "Fn::Sub": "${{Conglomerate}}:{0}" }} }}'.format(att.replace('.',':')))
|
||||||
|
else:
|
||||||
|
# We need to replace . with some PureAlphaNumeric thx AWS ...
|
||||||
|
return('{{ Ref: {0} }}'.format(att.replace('.','DoT')))
|
||||||
|
|
||||||
|
|
||||||
|
@jinja2.contextfunction
|
||||||
|
def include_raw_gz(context, files=None, gz=True):
|
||||||
|
jenv = context.environment
|
||||||
|
output = ''
|
||||||
|
for name in files:
|
||||||
|
output = output + jinja2.Markup(jenv.loader.get_source(jenv, name)[0])
|
||||||
|
|
||||||
|
# logger.debug(output)
|
||||||
|
|
||||||
|
if not gz:
|
||||||
|
return(output)
|
||||||
|
|
||||||
|
buf = io.BytesIO()
|
||||||
|
f = gzip.GzipFile(mode='w', fileobj=buf, mtime=0)
|
||||||
|
f.write(output.encode())
|
||||||
|
f.close()
|
||||||
|
|
||||||
|
return base64.b64encode(buf.getvalue()).decode('utf-8')
|
||||||
|
|
||||||
|
|
||||||
|
@jinja2.contextfunction
|
||||||
|
def render_once(context, name=None, resources=set(), reset=False):
|
||||||
|
""" Utility function to True only once """
|
||||||
|
|
||||||
|
if reset:
|
||||||
|
resources.clear()
|
||||||
|
return
|
||||||
|
|
||||||
|
if name and name not in resources:
|
||||||
|
resources.add(name)
|
||||||
|
return True
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@jinja2.contextfunction
|
||||||
|
def raise_helper(context, msg):
|
||||||
|
raise Exception(msg)
|
||||||
|
|
||||||
|
|
||||||
|
# Custom tests
|
||||||
|
def regex(value='', pattern='', ignorecase=False, match_type='search'):
|
||||||
|
''' Expose `re` as a boolean filter using the `search` method by default.
|
||||||
|
This is likely only useful for `search` and `match` which already
|
||||||
|
have their own filters.
|
||||||
|
'''
|
||||||
|
if ignorecase:
|
||||||
|
flags = re.I
|
||||||
|
else:
|
||||||
|
flags = 0
|
||||||
|
_re = re.compile(pattern, flags=flags)
|
||||||
|
if getattr(_re, match_type, 'search')(value) is not None:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def match(value, pattern='', ignorecase=False):
|
||||||
|
''' Perform a `re.match` returning a boolean '''
|
||||||
|
return regex(value, pattern, ignorecase, 'match')
|
||||||
|
|
||||||
|
|
||||||
|
def search(value, pattern='', ignorecase=False):
|
||||||
|
''' Perform a `re.search` returning a boolean '''
|
||||||
|
return regex(value, pattern, ignorecase, 'search')
|
||||||
|
|
||||||
|
|
||||||
|
# Custom filters
|
||||||
|
def regex_replace(value='', pattern='', replace='', ignorecase=False):
|
||||||
|
if ignorecase:
|
||||||
|
flags = re.I
|
||||||
|
else:
|
||||||
|
flags = 0
|
||||||
|
return re.sub(pattern,replace,value,flags=flags)
|
||||||
|
|
||||||
|
|
||||||
|
def pyminify(source, obfuscate=False, minify=True):
|
||||||
|
# pyminifier options
|
||||||
|
options = types.SimpleNamespace(tabs=False,replacement_length=1,use_nonlatin=0,
|
||||||
|
obfuscate=0,obf_variables=1,obf_classes=0,obf_functions=0,obf_import_methods=0,obf_builtins=0)
|
||||||
|
|
||||||
|
tokens = pyminifier.token_utils.listified_tokenizer(source)
|
||||||
|
|
||||||
|
if minify:
|
||||||
|
source = pyminifier.minification.minify(tokens, options)
|
||||||
|
tokens = pyminifier.token_utils.listified_tokenizer(source)
|
||||||
|
|
||||||
|
if obfuscate:
|
||||||
|
name_generator = pyminifier.obfuscate.obfuscation_machine(use_unicode=False)
|
||||||
|
pyminifier.obfuscate.obfuscate("__main__", tokens, options, name_generator=name_generator)
|
||||||
|
#source = pyminifier.obfuscate.apply_obfuscation(source)
|
||||||
|
|
||||||
|
source = pyminifier.token_utils.untokenize(tokens)
|
||||||
|
# logger.debug(source)
|
||||||
|
minified_source = pyminifier.compression.gz_pack(source)
|
||||||
|
logger.info("Compressed python code to {}".format(len(minified_source)))
|
||||||
|
return minified_source
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def JinjaEnv(template_locations=[]):
|
||||||
|
jenv = jinja2.Environment(trim_blocks=True, lstrip_blocks=True, undefined=jinja2.Undefined)
|
||||||
|
|
||||||
|
jinja_loaders = []
|
||||||
|
for _dir in template_locations:
|
||||||
|
jinja_loaders.append(jinja2.FileSystemLoader(_dir))
|
||||||
|
jenv.loader = jinja2.ChoiceLoader(jinja_loaders)
|
||||||
|
|
||||||
|
jenv.globals['include_raw'] = include_raw_gz
|
||||||
|
jenv.globals['get_custom_att'] = get_custom_att
|
||||||
|
jenv.globals['render_once'] = render_once
|
||||||
|
jenv.globals['raise'] = raise_helper
|
||||||
|
|
||||||
|
jenv.filters['regex_replace'] = regex_replace
|
||||||
|
jenv.filters['pyminify'] = pyminify
|
||||||
|
|
||||||
|
jenv.tests['match'] = match
|
||||||
|
jenv.tests['regex'] = regex
|
||||||
|
jenv.tests['search'] = search
|
||||||
|
|
||||||
|
return jenv
|
486
cloudbender/stack.py
Normal file
486
cloudbender/stack.py
Normal file
@ -0,0 +1,486 @@
|
|||||||
|
import os
|
||||||
|
import re
|
||||||
|
import semver
|
||||||
|
import hashlib
|
||||||
|
import oyaml as yaml
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from dateutil.tz import tzutc
|
||||||
|
|
||||||
|
import botocore
|
||||||
|
from botocore.exceptions import ClientError
|
||||||
|
|
||||||
|
from .utils import read_yaml_file, dict_merge
|
||||||
|
from .connection import BotoConnection
|
||||||
|
from .jinja import JinjaEnv
|
||||||
|
from . import __version__
|
||||||
|
|
||||||
|
import cfnlint.core
|
||||||
|
|
||||||
|
import logging
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class StackStatus(object):
|
||||||
|
"""
|
||||||
|
StackStatus stores simplified stack statuses.
|
||||||
|
"""
|
||||||
|
COMPLETE = "complete"
|
||||||
|
FAILED = "failed"
|
||||||
|
IN_PROGRESS = "in progress"
|
||||||
|
PENDING = "pending"
|
||||||
|
|
||||||
|
|
||||||
|
class Stack(object):
|
||||||
|
def __init__(self, name, path, rel_path, tags=None, parameters=None, template_vars=None, region='global', profile=None, template=None, ctx={}):
|
||||||
|
self.id = (profile, region, name)
|
||||||
|
self.stackname = name
|
||||||
|
self.path = path
|
||||||
|
self.rel_path = rel_path
|
||||||
|
self.tags = tags
|
||||||
|
self.parameters = parameters
|
||||||
|
self.template_vars = template_vars
|
||||||
|
self.region = region
|
||||||
|
self.profile = profile
|
||||||
|
self.template = template
|
||||||
|
self.provides = template
|
||||||
|
self.cfn_template = None
|
||||||
|
self.cfn_parameters = []
|
||||||
|
self.connection_manager = BotoConnection(self.profile, self.region)
|
||||||
|
self.ctx = ctx
|
||||||
|
self.status = None
|
||||||
|
self.dependencies = set()
|
||||||
|
self.default_lock = None
|
||||||
|
self.multi_delete = True
|
||||||
|
|
||||||
|
|
||||||
|
def dump_config(self):
|
||||||
|
logger.debug("<Stack {}: {}>".format(self.id, vars(self)))
|
||||||
|
|
||||||
|
|
||||||
|
def read_config(self):
|
||||||
|
_config = read_yaml_file(self.path)
|
||||||
|
for p in ["stackname", "template", "dependencies", "default_lock", "multi_delete", "provides"]:
|
||||||
|
if p in _config:
|
||||||
|
setattr(self, p, _config[p])
|
||||||
|
|
||||||
|
for p in ["parameters", "tags"]:
|
||||||
|
if p in _config:
|
||||||
|
setattr(self, p, dict_merge(getattr(self, p), _config[p]))
|
||||||
|
|
||||||
|
# Inject Artifact for now hard coded
|
||||||
|
self.tags['Artifact'] = self.provides
|
||||||
|
|
||||||
|
if 'vars' in _config:
|
||||||
|
self.template_vars = dict_merge(self.template_vars, _config['vars'])
|
||||||
|
|
||||||
|
logger.info("Stack {} added.".format(self.id))
|
||||||
|
|
||||||
|
|
||||||
|
def check_fortytwo(self, template):
|
||||||
|
# Fail early if 42 is enabled but not available
|
||||||
|
if self.cfn['Mode'] == "FortyTwo" and self.template != 'FortyTwo':
|
||||||
|
try:
|
||||||
|
response = self.connection_manager.call('lambda', 'get_function', {'FunctionName': 'FortyTwo'},
|
||||||
|
profile=self.profile, region=self.region)
|
||||||
|
|
||||||
|
# Also verify version in case specified in the template's metadata
|
||||||
|
try:
|
||||||
|
req_ver = template['Metadata']['FortyTwo']['RequiredVersion']
|
||||||
|
if 'Release' not in response['Tags']:
|
||||||
|
abort("Lambda FortyTwo has no Release Tag! Required: {}".format(req_ver))
|
||||||
|
elif semver.compare(req_ver, re.sub("-.*$",'', response['Tags']['Release'])) > 0:
|
||||||
|
abort("Lambda FortyTwo version is not recent enough! Required: {} vs. Found: {}".format(req_ver, response['Tags']['Release']))
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
except botocore.exceptions.ClientError:
|
||||||
|
abort("No Lambda FortyTwo found in your account")
|
||||||
|
|
||||||
|
|
||||||
|
def render(self):
|
||||||
|
"""Renders the cfn jinja template for this stack"""
|
||||||
|
|
||||||
|
jenv = JinjaEnv(self.ctx['artifact_paths'])
|
||||||
|
|
||||||
|
template = jenv.get_template('{0}{1}'.format(self.template, '.yaml.jinja'))
|
||||||
|
|
||||||
|
template_metadata = {
|
||||||
|
'Template.Name': self.template,
|
||||||
|
'Template.Hash': 'unknown',
|
||||||
|
'Template.GitComment': 'unknown',
|
||||||
|
'CloudBender.Version': __version__
|
||||||
|
}
|
||||||
|
|
||||||
|
jenv.globals['_config'] = { 'cfn': self.template_vars, 'Metadata': template_metadata }
|
||||||
|
|
||||||
|
# First render pass to calculate a md5 checksum
|
||||||
|
template_metadata['Template.Hash'] = hashlib.md5(template.render({ 'cfn': self.template_vars, 'Metadata': template_metadata }).encode('utf-8')).hexdigest()
|
||||||
|
|
||||||
|
# Reset and set Metadata for final render pass
|
||||||
|
jenv.globals['get_custom_att'](context={'_config': self.template_vars}, flush=True)
|
||||||
|
jenv.globals['render_once'](context={'_config': self.template_vars}, reset=True)
|
||||||
|
|
||||||
|
# try to get local git info
|
||||||
|
try:
|
||||||
|
self.template_vars['Metadata']['{}.Version'.format(PROJECT_NAME)] = subprocess.check_output('git describe --tags'.split(' '), universal_newlines=True)[:-1]
|
||||||
|
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Add latest tag/commit
|
||||||
|
try:
|
||||||
|
os.chdir(ROOT_DIR)
|
||||||
|
_version = subprocess.check_output('git describe --tags'.split(' '), universal_newlines=True)[:-1]
|
||||||
|
if _version:
|
||||||
|
self.template_vars['Metadata']['CloudBender.Version'] = _version
|
||||||
|
|
||||||
|
os.chdir(os.path.dirname(template.filename))
|
||||||
|
_comment = subprocess.check_output('git log -1 --pretty=%B {0}{1}'
|
||||||
|
.format(input_file, TEMPLATE_EXT).split(' ')).decode('utf-8').strip() \
|
||||||
|
.replace('"', '').replace('#', '').replace('\n', '').replace(':', ' ')
|
||||||
|
if _comment:
|
||||||
|
self.template_vars['Metadata']['Template.GitComment'] = _comment
|
||||||
|
|
||||||
|
os.chdir(PROJECT_DIR)
|
||||||
|
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
logger.info('Rendering %s', template.filename)
|
||||||
|
rendered = template.render({ 'cfn': self.template_vars, 'Metadata': template_metadata })
|
||||||
|
|
||||||
|
try:
|
||||||
|
data = yaml.load(rendered)
|
||||||
|
except:
|
||||||
|
# In case we rendered invalid yaml this helps to debug
|
||||||
|
logger.error(rendered)
|
||||||
|
raise
|
||||||
|
|
||||||
|
# Some sanity checks and final cosmetics
|
||||||
|
# Check for empty top level Parameters, Outputs and Conditions and remove
|
||||||
|
for key in ['Parameters', 'Outputs', 'Conditions']:
|
||||||
|
if key in data and data[key] is None:
|
||||||
|
# Delete from data structure which also takes care of json
|
||||||
|
del data[key]
|
||||||
|
# but also remove from rendered for the yaml file
|
||||||
|
rendered = rendered.replace('\n'+key+":",'')
|
||||||
|
|
||||||
|
# Condense multiple empty lines to one
|
||||||
|
self.cfn_template = re.sub(r'\n\s*\n', '\n\n', rendered)
|
||||||
|
|
||||||
|
|
||||||
|
def write_template_file(self):
|
||||||
|
if self.cfn_template:
|
||||||
|
yaml_file = os.path.join(self.ctx['template_path'], self.rel_path, self.stackname+".yaml")
|
||||||
|
self._ensure_dirs('template_path')
|
||||||
|
with open(yaml_file, 'w') as yaml_contents:
|
||||||
|
yaml_contents.write(self.cfn_template)
|
||||||
|
logger.info('Wrote %s to %s', self.template, yaml_file)
|
||||||
|
|
||||||
|
else:
|
||||||
|
logger.error('No cfn template rendered yet for stack {}.'.format(self.stackname))
|
||||||
|
|
||||||
|
|
||||||
|
def delete_template_file(self):
|
||||||
|
yaml_file = os.path.join(self.ctx['template_path'], self.rel_path, self.stackname+".yaml")
|
||||||
|
try:
|
||||||
|
os.remove(yaml_file)
|
||||||
|
logger.debug('Deleted cfn template %s.', yaml_file)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def read_template_file(self):
|
||||||
|
yaml_file = os.path.join(self.ctx['template_path'], self.rel_path, self.stackname+".yaml")
|
||||||
|
with open(yaml_file, 'r') as yaml_contents:
|
||||||
|
self.cfn_template = yaml_contents.read()
|
||||||
|
logger.debug('Read cfn template %s.', yaml_file)
|
||||||
|
|
||||||
|
|
||||||
|
def validate(self):
|
||||||
|
"""Validates the rendered template via cfn-lint"""
|
||||||
|
if not self.cfn_template:
|
||||||
|
self.read_template_file()
|
||||||
|
|
||||||
|
data = yaml.load(self.cfn_template)
|
||||||
|
try:
|
||||||
|
ignore_checks = data['Metadata']['cfnlint_ignore']
|
||||||
|
except KeyError:
|
||||||
|
ignore_checks = []
|
||||||
|
|
||||||
|
# Ignore some more checks around injected parameters as we generate these
|
||||||
|
if self.template_vars['Mode'] == "Piped":
|
||||||
|
ignore_checks = ignore_checks+['W2505','W2509','W2507']
|
||||||
|
|
||||||
|
filename = os.path.join(self.ctx['template_path'], self.rel_path, self.stackname+".yaml")
|
||||||
|
logger.info('Validating {0}'.format(filename))
|
||||||
|
|
||||||
|
lint_args = ['--template', filename]
|
||||||
|
if ignore_checks:
|
||||||
|
lint_args.append('--ignore-checks')
|
||||||
|
lint_args = lint_args+ignore_checks
|
||||||
|
logger.info('Ignoring checks: {}'.format(','.join(ignore_checks)))
|
||||||
|
|
||||||
|
(args, filenames, formatter) = cfnlint.core.get_args_filenames(lint_args)
|
||||||
|
(template, rules, matches) = cfnlint.core.get_template_rules(filename, args)
|
||||||
|
if not matches:
|
||||||
|
matches.extend(cfnlint.core.run_cli(filename, template, rules, ['us-east-1'], None))
|
||||||
|
if len(matches):
|
||||||
|
for match in matches:
|
||||||
|
logger.error(formatter._format(match))
|
||||||
|
else:
|
||||||
|
logger.info("Passed.")
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_parameters(self):
|
||||||
|
""" Renders parameters for the stack based on the source template and the environment configuration """
|
||||||
|
|
||||||
|
if not self.cfn_template:
|
||||||
|
self.read_template_file()
|
||||||
|
|
||||||
|
data = yaml.load(self.cfn_template)
|
||||||
|
|
||||||
|
# Inspect all outputs of the running Conglomerate members
|
||||||
|
# if we run in Piped Mode
|
||||||
|
# if self.template_vars['Mode'] == "Piped":
|
||||||
|
# try:
|
||||||
|
# stack_outputs = inspect_stacks(config['tags']['Conglomerate'])
|
||||||
|
# logger.info(pprint.pformat(stack_outputs))
|
||||||
|
# except KeyError:
|
||||||
|
# pass
|
||||||
|
|
||||||
|
if 'Parameters' in data:
|
||||||
|
self.cfn_parameters = []
|
||||||
|
for p in data['Parameters']:
|
||||||
|
# In Piped mode we try to resolve all Paramters first via stack_outputs
|
||||||
|
#if config['cfn']['Mode'] == "Piped":
|
||||||
|
# try:
|
||||||
|
# # first reverse the rename due to AWS alphanumeric restriction for parameter names
|
||||||
|
# _p = p.replace('DoT','.')
|
||||||
|
# value = str(stack_outputs[_p])
|
||||||
|
# parameters.append({'ParameterKey': p, 'ParameterValue': value })
|
||||||
|
# logger.info('Got {} = {} from running stack'.format(p,value))
|
||||||
|
# continue
|
||||||
|
# except KeyError:
|
||||||
|
# pass
|
||||||
|
|
||||||
|
# Key name in config tree is: stacks.<self.stackname>.parameters.<parameter>
|
||||||
|
try:
|
||||||
|
value = str(self.parameters[p])
|
||||||
|
self.cfn_parameters.append({'ParameterKey': p, 'ParameterValue': value })
|
||||||
|
logger.info('Got {} = {}'.format(p,value))
|
||||||
|
except KeyError as e:
|
||||||
|
# If we have a Default defined in the CFN skip, as AWS will use it
|
||||||
|
if 'Default' in data['Parameters'][p]:
|
||||||
|
continue
|
||||||
|
else:
|
||||||
|
logger.error('Cannot find value for parameter {0}'.format(p))
|
||||||
|
|
||||||
|
|
||||||
|
def write_parameter_file(self):
|
||||||
|
parameter_file = os.path.join(self.ctx['parameter_path'], self.rel_path, self.stackname+".yaml")
|
||||||
|
|
||||||
|
# Render parameters as json for AWS CFN
|
||||||
|
self._ensure_dirs('parameter_path')
|
||||||
|
with open(parameter_file, 'w') as parameter_contents:
|
||||||
|
parameter_contents.write(json.dumps(self.cfn_parameters, indent=2, separators=(',', ': '), sort_keys=True))
|
||||||
|
logger.info('Wrote json parameters for %s to %s', self.stackname, parameter_file)
|
||||||
|
|
||||||
|
if not self.cfn_parameters:
|
||||||
|
# Make sure there are no parameters from previous runs
|
||||||
|
if os.path.isfile(parameter_file):
|
||||||
|
os.remove(parameter_file)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_parameter_file(self):
|
||||||
|
parameter_file = os.path.join(self.ctx['parameter_path'], self.rel_path, self.stackname+".yaml")
|
||||||
|
try:
|
||||||
|
os.remove(parameter_file)
|
||||||
|
logger.debug('Deleted parameter %s.', parameter_file)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def create(self):
|
||||||
|
"""Creates a stack """
|
||||||
|
|
||||||
|
# Prepare parameters
|
||||||
|
self.resolve_parameters()
|
||||||
|
self.write_parameter_file()
|
||||||
|
|
||||||
|
if not self.cfn_template:
|
||||||
|
self.read_template_file()
|
||||||
|
|
||||||
|
logger.info('Creating {0}'.format(self.stackname))
|
||||||
|
response = self.connection_manager.call('cloudformation', 'create_stack',
|
||||||
|
{'StackName':self.stackname,
|
||||||
|
'TemplateBody':self.cfn_template,
|
||||||
|
'Parameters':self.cfn_parameters,
|
||||||
|
'Tags':[ {"Key": str(k), "Value": str(v)} for k, v in self.tags.items() ],
|
||||||
|
'Capabilities':['CAPABILITY_IAM', 'CAPABILITY_NAMED_IAM']},
|
||||||
|
profile=self.profile, region=self.region)
|
||||||
|
|
||||||
|
return self._wait_for_completion()
|
||||||
|
|
||||||
|
|
||||||
|
def update(self):
|
||||||
|
"""Updates an existing stack """
|
||||||
|
|
||||||
|
# Prepare parameters
|
||||||
|
self.resolve_parameters()
|
||||||
|
self.write_parameter_file()
|
||||||
|
|
||||||
|
if not self.cfn_template:
|
||||||
|
self.read_template_file()
|
||||||
|
|
||||||
|
logger.info('Updating {0}'.format(self.stackname))
|
||||||
|
try:
|
||||||
|
response = self.connection_manager.call('cloudformation', 'update_stack',
|
||||||
|
{'StackName':self.stackname,
|
||||||
|
'TemplateBody':self.cfn_template,
|
||||||
|
'Parameters':self.cfn_parameters,
|
||||||
|
'Tags':[ {"Key": str(k), "Value": str(v)} for k, v in self.tags.items() ],
|
||||||
|
'Capabilities':['CAPABILITY_IAM', 'CAPABILITY_NAMED_IAM']},
|
||||||
|
profile=self.profile, region=self.region)
|
||||||
|
|
||||||
|
except ClientError as e:
|
||||||
|
if 'No updates are to be performed' in e.response['Error']['Message']:
|
||||||
|
logger.info('No updates for {0}'.format(self.stackname))
|
||||||
|
return StackStatus.COMPLETE
|
||||||
|
else:
|
||||||
|
raise e
|
||||||
|
|
||||||
|
return self._wait_for_completion()
|
||||||
|
|
||||||
|
|
||||||
|
def delete(self):
|
||||||
|
"""Deletes a stack """
|
||||||
|
|
||||||
|
logger.info('Deleting {0}'.format(self.stackname))
|
||||||
|
response = self.connection_manager.call('cloudformation', 'delete_stack',
|
||||||
|
{'StackName':self.stackname}, profile=self.profile, region=self.region)
|
||||||
|
|
||||||
|
return self._wait_for_completion()
|
||||||
|
|
||||||
|
|
||||||
|
def describe(self):
|
||||||
|
"""
|
||||||
|
Returns the a description of the stack.
|
||||||
|
:returns: A stack description.
|
||||||
|
"""
|
||||||
|
return self.connection_manager.call(
|
||||||
|
"cloudformation",
|
||||||
|
"describe_stacks",
|
||||||
|
{"StackName": self.stackname},
|
||||||
|
profile=self.profile, region=self.region)
|
||||||
|
|
||||||
|
|
||||||
|
def get_status(self):
|
||||||
|
"""
|
||||||
|
Returns the stack's status.
|
||||||
|
:returns: The stack's status.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
status = self.describe()["Stacks"][0]["StackStatus"]
|
||||||
|
except ClientError as e:
|
||||||
|
if e.response["Error"]["Message"].endswith("does not exist"):
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
raise e
|
||||||
|
return status
|
||||||
|
|
||||||
|
|
||||||
|
def describe_events(self):
|
||||||
|
"""
|
||||||
|
Returns a dictionary contianing the stack events.
|
||||||
|
:returns: The CloudFormation events for a stack.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
status = self.connection_manager.call(
|
||||||
|
"cloudformation",
|
||||||
|
"describe_stack_events",
|
||||||
|
{"StackName": self.stackname},
|
||||||
|
profile=self.profile, region=self.region)
|
||||||
|
except ClientError as e:
|
||||||
|
if e.response["Error"]["Message"].endswith("does not exist"):
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
raise e
|
||||||
|
|
||||||
|
return status
|
||||||
|
|
||||||
|
|
||||||
|
def _wait_for_completion(self, timeout=0):
|
||||||
|
"""
|
||||||
|
Waits for a stack operation to finish. Prints CloudFormation events while it waits.
|
||||||
|
:param timeout: Timeout before returning
|
||||||
|
:returns: The final stack status.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def timed_out(elapsed):
|
||||||
|
return elapsed >= timeout if timeout else False
|
||||||
|
|
||||||
|
status = StackStatus.IN_PROGRESS
|
||||||
|
|
||||||
|
self.most_recent_event_datetime = (
|
||||||
|
datetime.now(tzutc()) - timedelta(seconds=3)
|
||||||
|
)
|
||||||
|
elapsed = 0
|
||||||
|
while status == StackStatus.IN_PROGRESS and not timed_out(elapsed):
|
||||||
|
status = self._get_simplified_status(self.get_status())
|
||||||
|
if not status:
|
||||||
|
return None
|
||||||
|
|
||||||
|
self._log_new_events()
|
||||||
|
time.sleep(4)
|
||||||
|
elapsed += 4
|
||||||
|
|
||||||
|
return status
|
||||||
|
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _get_simplified_status(status):
|
||||||
|
""" Returns the simplified Stack Status. """
|
||||||
|
if status:
|
||||||
|
if status.endswith("ROLLBACK_COMPLETE"):
|
||||||
|
return StackStatus.FAILED
|
||||||
|
elif status.endswith("_COMPLETE"):
|
||||||
|
return StackStatus.COMPLETE
|
||||||
|
elif status.endswith("_IN_PROGRESS"):
|
||||||
|
return StackStatus.IN_PROGRESS
|
||||||
|
elif status.endswith("_FAILED"):
|
||||||
|
return StackStatus.FAILED
|
||||||
|
else:
|
||||||
|
return 'Unknown'
|
||||||
|
|
||||||
|
|
||||||
|
def _log_new_events(self):
|
||||||
|
"""
|
||||||
|
Log the latest stack events while the stack is being built.
|
||||||
|
"""
|
||||||
|
events = self.describe_events()
|
||||||
|
if events:
|
||||||
|
events = events["StackEvents"]
|
||||||
|
events.reverse()
|
||||||
|
new_events = [
|
||||||
|
event for event in events
|
||||||
|
if event["Timestamp"] > self.most_recent_event_datetime
|
||||||
|
]
|
||||||
|
for event in new_events:
|
||||||
|
logger.info(" ".join([
|
||||||
|
self.stackname,
|
||||||
|
event["LogicalResourceId"],
|
||||||
|
event["ResourceType"],
|
||||||
|
event["ResourceStatus"],
|
||||||
|
event.get("ResourceStatusReason", "")
|
||||||
|
]))
|
||||||
|
self.most_recent_event_datetime = event["Timestamp"]
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_dirs(self, path):
|
||||||
|
# Ensure output dirs exist
|
||||||
|
if not os.path.exists(os.path.join(self.ctx[path], self.rel_path)):
|
||||||
|
os.makedirs(os.path.join(self.ctx[path], self.rel_path))
|
169
cloudbender/stackgroup.py
Normal file
169
cloudbender/stackgroup.py
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
import os
|
||||||
|
import glob
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from .utils import read_yaml_file, dict_merge
|
||||||
|
from .stack import Stack
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class StackGroup(object):
|
||||||
|
def __init__(self, path, ctx):
|
||||||
|
self.name = None
|
||||||
|
self.ctx = ctx
|
||||||
|
self.path = path
|
||||||
|
self.rel_path = os.path.relpath(path ,ctx['config_path'])
|
||||||
|
self.config = {}
|
||||||
|
self.sgs = []
|
||||||
|
self.stacks = []
|
||||||
|
|
||||||
|
if self.rel_path == '.':
|
||||||
|
self.rel_path = ''
|
||||||
|
|
||||||
|
|
||||||
|
def dump_config(self):
|
||||||
|
for sg in self.sgs:
|
||||||
|
sg.dump_config()
|
||||||
|
|
||||||
|
logger.debug("<StackGroup {}: {}>".format(self.name, vars(self)))
|
||||||
|
|
||||||
|
for s in self.stacks:
|
||||||
|
s.dump_config()
|
||||||
|
|
||||||
|
|
||||||
|
def read_config(self, parent_config={}):
|
||||||
|
|
||||||
|
if not os.path.isdir(self.path):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# First read config.yaml if present
|
||||||
|
_config = read_yaml_file(os.path.join(self.path, 'config.yaml'))
|
||||||
|
|
||||||
|
# Stack Group name if not explicit via config is derived from subfolder, or in case of root object the parent folder
|
||||||
|
if "stackgroupname" in _config:
|
||||||
|
self.name = _config["stackgroupname"]
|
||||||
|
elif not self.name:
|
||||||
|
self.name = os.path.split(self.path)[1]
|
||||||
|
|
||||||
|
# Merge config with parent config
|
||||||
|
_config = dict_merge(parent_config, _config)
|
||||||
|
|
||||||
|
tags = _config.get('tags', {})
|
||||||
|
parameters = _config.get('parameters', {})
|
||||||
|
template_vars = _config.get('vars', {})
|
||||||
|
region = _config.get('region', 'global')
|
||||||
|
profile = _config.get('profile', '')
|
||||||
|
stackname_prefix = _config.get('stacknameprefix', '')
|
||||||
|
|
||||||
|
logger.info("StackGroup {} added.".format(self.name))
|
||||||
|
|
||||||
|
# Add stacks
|
||||||
|
stacks = [s for s in glob.glob(os.path.join(self.path, '*.yaml')) if not s.endswith("config.yaml")]
|
||||||
|
for stack_path in stacks:
|
||||||
|
stackname = os.path.basename(stack_path).split('.')[0]
|
||||||
|
template = stackname
|
||||||
|
if stackname_prefix:
|
||||||
|
stackname = stackname_prefix + stackname
|
||||||
|
|
||||||
|
new_stack = Stack(name=stackname, template=template,
|
||||||
|
path=stack_path, rel_path=str(self.rel_path),
|
||||||
|
tags=dict(tags), parameters=dict(parameters),
|
||||||
|
template_vars=dict(template_vars),
|
||||||
|
region=str(region), profile=str(profile),
|
||||||
|
ctx=self.ctx
|
||||||
|
)
|
||||||
|
new_stack.read_config()
|
||||||
|
self.stacks.append(new_stack)
|
||||||
|
|
||||||
|
# Create StackGroups recursively
|
||||||
|
for sub_group in [f.path for f in os.scandir(self.path) if f.is_dir() ]:
|
||||||
|
sg = StackGroup(sub_group, self.ctx)
|
||||||
|
sg.read_config(_config)
|
||||||
|
|
||||||
|
self.sgs.append(sg)
|
||||||
|
|
||||||
|
# Return raw, merged config to parent
|
||||||
|
return _config
|
||||||
|
|
||||||
|
|
||||||
|
def get_stacks(self, name=None, recursive=True, match_by='name'):
|
||||||
|
""" Returns [stack] matching stack_name or [all] """
|
||||||
|
stacks = []
|
||||||
|
if name:
|
||||||
|
logger.debug("Looking for stack {} in group {}".format(name, self.name))
|
||||||
|
|
||||||
|
for s in self.stacks:
|
||||||
|
if not name or (s.stackname == name and match_by == 'name') or (s.path.endswith(name) and match_by == 'path'):
|
||||||
|
if self.rel_path:
|
||||||
|
logger.debug("Found stack {} in group {}".format(s.stackname, self.rel_path))
|
||||||
|
else:
|
||||||
|
logger.debug("Found stack {}".format(s.stackname))
|
||||||
|
stacks.append(s)
|
||||||
|
|
||||||
|
if recursive:
|
||||||
|
for sg in self.sgs:
|
||||||
|
s = sg.get_stacks(name, recursive, match_by)
|
||||||
|
if s:
|
||||||
|
stacks = stacks+s
|
||||||
|
|
||||||
|
return stacks
|
||||||
|
|
||||||
|
|
||||||
|
def get_stackgroup(self, name=None, recursive=True, match_by='name'):
|
||||||
|
""" Returns stack group matching stackgroup_name or all if None """
|
||||||
|
if not name or (self.name == name and match_by == 'name') or (self.path.endswith(name) and match_by == 'path'):
|
||||||
|
logger.debug("Found stack_group {}".format(self.name))
|
||||||
|
return self
|
||||||
|
|
||||||
|
if name and name != 'config':
|
||||||
|
logger.debug("Looking for stack_group {} in group {}".format(name, self.name))
|
||||||
|
|
||||||
|
if recursive:
|
||||||
|
for sg in self.sgs:
|
||||||
|
s = sg.get_stackgroup(name, recursive, match_by)
|
||||||
|
if s:
|
||||||
|
return s
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# TODO: Integrate properly into stackgroup class, borken for now
|
||||||
|
# stackoutput inspection
|
||||||
|
def BROKEN_inspect_stacks(conglomerate):
|
||||||
|
# Get all stacks of the conglomertate
|
||||||
|
client = Connection.get_connection('cloudformation')
|
||||||
|
running_stacks=client.describe_stacks()
|
||||||
|
|
||||||
|
stacks = []
|
||||||
|
for stack in running_stacks['Stacks']:
|
||||||
|
for tag in stack['Tags']:
|
||||||
|
if tag['Key'] == 'Conglomerate' and tag['Value'] == conglomerate:
|
||||||
|
stacks.append(stack)
|
||||||
|
break
|
||||||
|
|
||||||
|
|
||||||
|
# Gather stack outputs, use Tag['Artifact'] as name space: Artifact.OutputName, same as FortyTwo
|
||||||
|
stack_outputs = {}
|
||||||
|
for stack in stacks:
|
||||||
|
# If stack has an Artifact Tag put resources into the namespace Artifact.Resource
|
||||||
|
artifact = None
|
||||||
|
for tag in stack['Tags']:
|
||||||
|
if tag['Key'] == 'Artifact':
|
||||||
|
artifact = tag['Value']
|
||||||
|
|
||||||
|
if artifact:
|
||||||
|
key_prefix = "{}.".format(artifact)
|
||||||
|
else:
|
||||||
|
key_prefix = ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
for output in stack['Outputs']:
|
||||||
|
# Gather all outputs of the stack into one dimensional key=value structure
|
||||||
|
stack_outputs[key_prefix+output['OutputKey']]=output['OutputValue']
|
||||||
|
except KeyError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Add outputs from stacks into the data for jinja under StackOutput
|
||||||
|
return stack_outputs
|
||||||
|
|
70
cloudbender/utils.py
Normal file
70
cloudbender/utils.py
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import os
|
||||||
|
import yaml
|
||||||
|
import copy
|
||||||
|
import logging
|
||||||
|
import boto3
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
def read_yaml_file(path):
|
||||||
|
data = {}
|
||||||
|
if os.path.exists(path):
|
||||||
|
with open(path, 'r') as config_file_contents:
|
||||||
|
logger.debug("Reading config file: {}".format(path))
|
||||||
|
try:
|
||||||
|
_data = yaml.load(config_file_contents.read())
|
||||||
|
if _data:
|
||||||
|
data.update(_data)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Error reading config file: {} ({})".format(path,e))
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def dict_merge(a, b):
|
||||||
|
""" Deep merge to allow proper inheritance for config files"""
|
||||||
|
if not a:
|
||||||
|
return b
|
||||||
|
|
||||||
|
if not b:
|
||||||
|
return a
|
||||||
|
|
||||||
|
if not isinstance(a, dict) or not isinstance(b, dict):
|
||||||
|
raise TypeError
|
||||||
|
|
||||||
|
result = copy.deepcopy(a)
|
||||||
|
for k, v in b.items():
|
||||||
|
if k in result and isinstance(result[k], dict):
|
||||||
|
result[k] = dict_merge(result[k], v)
|
||||||
|
else:
|
||||||
|
result[k] = copy.deepcopy(v)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_dir(path):
|
||||||
|
"""Creates dir if it does not already exist."""
|
||||||
|
if not os.path.exists(path):
|
||||||
|
os.makedirs(path)
|
||||||
|
logger.info('Created directory: %s', path)
|
||||||
|
|
||||||
|
|
||||||
|
def setup_logging(debug):
|
||||||
|
if debug:
|
||||||
|
our_level = logging.DEBUG
|
||||||
|
# logging.getLogger("botocore").setLevel(logging.INFO)
|
||||||
|
boto3.set_stream_logger('')
|
||||||
|
else:
|
||||||
|
our_level = logging.INFO
|
||||||
|
logging.getLogger("botocore").setLevel(logging.CRITICAL)
|
||||||
|
|
||||||
|
formatter = logging.Formatter(
|
||||||
|
fmt="[%(asctime)s] - %(name)s - %(message)s",
|
||||||
|
datefmt="%Y-%m-%d %H:%M:%S"
|
||||||
|
)
|
||||||
|
|
||||||
|
log_handler = logging.StreamHandler()
|
||||||
|
log_handler.setFormatter(formatter)
|
||||||
|
logger = logging.getLogger("cloudbender")
|
||||||
|
logger.addHandler(log_handler)
|
||||||
|
logger.setLevel(our_level)
|
||||||
|
return logger
|
8
requirements.txt
Normal file
8
requirements.txt
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
boto3
|
||||||
|
Jinja2
|
||||||
|
oyaml
|
||||||
|
click
|
||||||
|
pytest
|
||||||
|
semver
|
||||||
|
pyminifier
|
||||||
|
cfn-lint
|
Loading…
Reference in New Issue
Block a user