"""
user calls a stack, passing a filepath to the template to parse.
The returned stack object has all params and resources for that stack,
Stacks will support higher level operations like "create","wake","sleep".
"""
from __future__ import print_function
from future.builtins import dict
import logging
from datetime import datetime
from arbiter import create_task
from arbiter.sync import run_tasks
from shepherd.config import Config
from shepherd.manifest import Manifest
from shepherd.common.exceptions import PluginError, StackError
from shepherd.common.utils import dict_contains, tasks_passed
logger = logging.getLogger(__name__)
_DEFAULT_NAME_FMT = '{stack_name}_{stack_creation}'
[docs]class Stack(object):
"""
The Stack object maintains and manipulates the list of cloud
resources that need to be provisioned.
"""
def __init__(self, name, config):
"""
Initializes the Stack.
Args:
name (str): the local stack name or suffix
config (Config): a config object for grabbing loaded
Resource & Storage plugins.
Attributes:
global_name (str): A global/unique name for the stack which
by default is defined by the following tags ``{stack_name}_{stack_creation}``
settings (dict): the settings from the config obj for reference later on when
restoring a stack from storage rather than making a new one.
tags (dict): tags for easier searching for stacks from storage.
Defaults:
1. 'stack_name': name supplied
2. 'stack_creation': timestamp of initialization
"""
self._name_fmt = _DEFAULT_NAME_FMT
self._local_name = name
self._global_name = None
self._config = config
self._config_name = config.name
self._settings = self._config.settings
self._resources = []
self._tags = {
'stack_name': self._local_name,
'stack_creation': datetime.strftime(
datetime.utcnow(),
"%Y-%m-%d-%H-%M-%S"
),
}
if 'stack_name_fmt' in self._config.settings:
self._name_fmt = self._config.stack_name_fmt
if 'tags' in self._settings and isinstance(self._settings.tags, dict):
self._tags.update(self._settings.tags)
self._global_name = self._name_fmt.format(**self._tags)
def __repr__(self):
return u"Stack({}. {}".format(
self._tags['stack_name'],
self._config_name
)
def __str__(self):
return self._local_name
@property
def local_name(self):
return self._local_name
@property
def global_name(self):
return self._global_name
@property
def settings(self):
return self._settings
@property
def tags(self):
return self._tags
@classmethod
[docs] def make(cls, name, config):
"""
Handles creating a stack instance.
Args:
name (str): the stack name
config (Config): the Config object
"""
# Build the Manifest which handles the
# parsing, loading, etc of the template files.
manifest = Manifest(config)
manifest.load()
manifest.parse()
manifest.map()
# Create our new Stack object.
stack = Stack(name, config)
# Finally, we call the stacks deserialize method
# giving it just the finished resources dict from
# the Manifest.
stack.deserialize_resources(manifest.resources)
manifest.clear()
return stack
@classmethod
[docs] def restore(cls, name, config):
"""
Handles restoring a stack with the given name
from the storage plugin specified in
config.settings
Args:
name (str): the stack name to look for in the storage plugin
config (the): the config object used to find the storage
plugin to load the stack from.
Raises:
PluginError: if the storage plugin listed in the config.settings
can't be found.
"""
logger.debug('Stack.restore: Storage setting=%s', config.settings.storage)
store_name = config.settings.storage.name
# Get storage plugin
stores = config.get_plugins(
category_name='Storage',
plugin_name=store_name
)
if stores:
store = stores[0]
store.configure(config.settings.storage.settings)
serialized = store.load(name)
if not serialized:
raise StackError(
'Could not find stack {} in store {}'
.format(name, store_name),
logger=logger
)
stack = Stack.deserialize(serialized)
else:
raise PluginError(
'Failed to locate storage plugin {}'.format(store_name)
)
return stack
[docs] def save(self):
"""
Saves this stack to the storage plugin specified
in config.settings.
Raises:
PluginError: if the storage plugin listed in the config.settings
can't be found.
"""
logger.debug('Stack.save: Storage settings=%s', self._config.settings.storage)
store_name = self._config.settings.storage.name
# Get the storage plugin
stores = self._config.get_plugins(
category_name='Storage',
plugin_name=store_name
)
if stores:
store = stores[0]
store.configure(self._config.settings.storage.settings)
store.dump(self.serialize())
else:
raise PluginError(
'Failed to locate storage plugin {}'.format(store_name)
)
@classmethod
[docs] def deserialize(cls, data):
"""
Builds a stack from the data dictionary.
Details:
* Uses the settings and config_name values to create a Config.
* Uses the local_name and the config to create a new Stack instance.
* sets the global_name, tags and resource list (by deserializing the resources)
Args:
data (dict): a dictionary holding the state of a stack
Returns:
TYPE: Description
"""
config = Config.make(settings=data['settings'], name=data['config_name'])
stack = Stack(data['local_name'], config)
stack._global_name = data['global_name']
stack._tags = data['tags']
stack.deserialize_resources(data['resources'])
return stack
[docs] def serialize(self):
"""
Serializes a stack by writing the following attributes to a dict.
1. local_name
2. global_name
3. config_name
4. settings
5. tags
6. resources (serialized)
Returns:
dict: containaining the stack stack state
"""
result = {
'local_name': self._local_name,
'global_name': self._global_name,
'config_name': self._config_name,
'settings': self._settings,
'tags': self._tags,
'resources': [],
}
for resource in self._resources:
result['resources'].append(resource.serialize())
return result
[docs] def get_global_resource_name(self, name):
"""
Generates the global name for a resources, which by defaults is
``{resource_name}_{stack_name}_{stack_creation}``
Note:
This global name is truncated to be only 64 characters long due some
restrictions with AWS.
Args:
name (str): local name of the resource
Returns:
str: the global name
"""
global_name = "{}_{}".format(name, self._global_name)
global_name = global_name[0:63] # some things can only be 64 long...
return global_name
[docs] def get_resource_by_name(self, local_name):
"""
Takes a string for the unique *local* resource name
and returns the resource that matches.
NOTE: if you somehow manage to change the object name
to no longer be unique this method will only return the first
one it finds.
Args:
local_name (str): local name of the resource to search by
Returns:
the resource or None
"""
for resource in self._resources:
if resource.local_name == local_name:
return resource
return None
[docs] def get_resource_by_type(self, resource_type):
"""
Takes a string representing the resource type you want to retrieve
and returns a list of all resources that match that type. The type is
determined by either the name of the resources class or by the resource
type attribute if one exists.
Args:
resource_type (str): the name of the resource type you like to get
eg: all Instances
Returns:
list: of the resource of that type.
"""
logger.info('Looking up resources of type %s', resource_type)
resources = []
for resource in self._resources:
if type(resource).__name__ == resource_type:
resources.append(resource)
elif hasattr(resource, 'type'):
if resource.type == resource_type:
resources.append(resource)
return resources
[docs] def task_function_wrapper(self, function):
"""
Wraps a resource function with
- saving the stack
- logging errors
- etc
Args:
function (function): the function to wrap
"""
try:
function()
self.save()
except Exception as exc:
logger.exception(exc)
[docs] def provision_resources(self, resources=None):
"""
Handles building a list of create tasks and
running them with dynamic dependency handling via
run_tasks. Roles back changes in case of failure.
ke: if provisioning fails any already provisioned resource
are deprovision automatically.
Args:
resources (list, optional): a list of the subset of resources in the stack
to provision. Defaults to all of them.
Raises:
StackError: if not all resources are successfully provisioned.
"""
logger.debug('Building create tasks list')
if resources is None:
logger.debug(
'No resources list provided. '
'Provisioning all stack resources.'
)
resources = self._resources
tasks = []
for resource in resources:
logger.info(
'Stack.provision_resources - %s marked for creation',
resource.local_name
)
logger.debug(
'Stack.provision_resources - Dependencies: %s',
resource.get_dependencies()
)
tasks.append(
create_task(
resource.local_name,
resource.create,
tuple(dep.local_name for dep in resource.get_dependencies())
)
)
# This should be in a try except cause arbiter won't catch anything
logger.info("Provisioning Resources ...")
results = run_tasks(tasks)
tasks_passed(
results,
logger,
msg='Failed to provision resources',
exception=StackError
)
[docs] def deprovision_resources(self, resources=None):
"""
Handles building a list of destroy tasks and
running them with dynamic dependency handling via
run_tasks.
NOTE: We are also responsible for inverting the
dependency cases to the standard creation dependencies.
Args:
resources (list, optional): a list of the subset of resources in the stack
to deprovision. Defaults to all of them.
Raises:
StackError: if not all resources are successfully deprovisioned.
"""
logger.debug('Building destroy tasks list')
if resources is None:
logger.debug(
'No resources list provided. '
'Deprovisioning all stack resources.'
)
resources = self._resources
inverse_dependencies = {}
for resource in resources:
inverse_dependencies[resource.local_name] = []
for dep in resource.get_dependencies():
if dep.local_name not in inverse_dependencies:
inverse_dependencies[dep.local_name] = []
inverse_dependencies[dep.local_name].append(resource.local_name)
tasks = []
for resource in resources:
logger.info(
'Stack.deprovision_resources - %s marked for deletion',
resource.local_name
)
logger.debug(
'Stack.deprovision_resources - Dependencies: %s',
inverse_dependencies[resource.local_name]
)
tasks.append(
create_task(
resource.local_name,
resource.destroy,
tuple(dep for dep in inverse_dependencies[resource.local_name])
)
)
# TODO: Should check for failed tasks and throw an exception and traceback
# This should be in a try except
logger.info("Deprovisioning Resources ...")
results = run_tasks(tasks)
tasks_passed(
results,
logger,
msg='Failed to deprovision resources',
exception=StackError
)
[docs] def deserialize_resources(self, resource_list):
"""
Given a list or resource dicts we:
1. loop over them
2. extract the resource plugin name from the 'Type'
3. get the plugin and deserialize it from the dict
4. add the deserialized resource to the list of resources.
Args:
resource_list (list): list of dictionaries which are to be
loaded into resource attributes
Raises:
PluginError: if no Resources have been loaded or if a particular
Resource type specified in each dict doesn't exist.
"""
for rsrc_dict in resource_list:
# Get the resource plugin and deserialize it with the dict
classname = rsrc_dict['type']
resources = self._config.get_plugins(
category_name='Resource',
plugin_name=classname
)
if not resources:
raise PluginError(
'Failed to locate resource plugin named {}'
.format(classname)
)
for resource in resources:
if resource.provider.lower() == rsrc_dict['provider'].lower():
# Simply deserialize the stack
resource.deserialize(rsrc_dict)
# Add stack tags to resource if they
# aren't already there ie: this will add stack level
# tags to resource that are being deserialized from a manifest.
newtags = resource.tags
newtags.update(self._tags)
resource.tags = newtags
# Set the stack reference on the resource to our stack.
resource.stack = self
self._resources.append(resource)
else:
raise PluginError(
'Failed to locate resource named {} for provider {}'
.format(classname, rsrc_dict['provider'].lower())
)