Skip to content

Commit

Permalink
Merge pull request #59 from mhahn/destroy-action
Browse files Browse the repository at this point in the history
Add destroy action
  • Loading branch information
phobologic committed Aug 13, 2015
2 parents 91e2768 + dd1bfe3 commit 8a3594b
Show file tree
Hide file tree
Showing 12 changed files with 333 additions and 18 deletions.
88 changes: 88 additions & 0 deletions stacker/actions/destroy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import logging

from .base import BaseAction
from ..exceptions import StackDoesNotExist
from ..plan import (
COMPLETE,
SKIPPED,
SUBMITTED,
Plan,
)

logger = logging.getLogger(__name__)


class Action(BaseAction):
"""Responsible for destroying CloudFormation stacks.
Generates a destruction plan based on stack dependencies. Stack
dependencies are reversed from the build action. For example, if a Stack B
requires Stack A during build, during destroy Stack A requires Stack B be
destroyed first.
The plan defaults to printing an outline of what will be destroyed. If
forced to execute, each stack will get destroyed in order.
"""

def _get_dependencies(self, stacks_dict):
dependencies = {}
for stack_name, stack in stacks_dict.iteritems():
required_stacks = stack.requires
if not required_stacks:
if stack_name not in dependencies:
dependencies[stack_name] = required_stacks
continue

for requirement in required_stacks:
dependencies.setdefault(requirement, set()).add(stack_name)
return dependencies

def _generate_plan(self):
plan = Plan(description='Destroy stacks')
stacks_dict = self.context.get_stacks_dict()
dependencies = self._get_dependencies(stacks_dict)
for stack_name in self.get_stack_execution_order(dependencies):
plan.add(
stacks_dict[stack_name],
run_func=self._destroy_stack,
requires=dependencies.get(stack_name),
)
return plan

def _destroy_stack(self, stack, **kwargs):
try:
provider_stack = self.provider.get_stack(stack.fqn)
except StackDoesNotExist:
logger.debug("Stack %s does not exist.", stack.fqn)
# Once the stack has been destroyed, it doesn't exist. If the
# status of the step was SUBMITTED, we know we just deleted it,
# otherwise it should be skipped
if kwargs.get('status', None) is SUBMITTED:
return COMPLETE
else:
return SKIPPED

logger.debug(
"Stack %s provider status: %s",
self.provider.get_stack_name(provider_stack),
self.provider.get_stack_status(provider_stack),
)
if self.provider.is_stack_destroyed(provider_stack):
return COMPLETE
elif self.provider.is_stack_in_progress(provider_stack):
return SUBMITTED
else:
self.provider.destroy_stack(provider_stack)
return SUBMITTED

def run(self, force, *args, **kwargs):
plan = self._generate_plan()
if force:
# need to generate a new plan to log since the outline sets the
# steps to COMPLETE in order to log them
debug_plan = self._generate_plan()
debug_plan.outline(logging.DEBUG)
plan.execute()
else:
plan.outline(message='To execute this plan, run with "-f, --force" flag.')
15 changes: 15 additions & 0 deletions stacker/commands/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ def add_subcommands(self, parser):
)
subcommand.add_arguments(subparser)
subparser.set_defaults(run=subcommand.run)
subparser.set_defaults(get_context_kwargs=subcommand.get_context_kwargs)

@property
def logger(self):
Expand Down Expand Up @@ -66,3 +67,17 @@ def run(self, options, **kwargs):

def configure(self, options, **kwargs):
pass

def get_context_kwargs(self, options, **kwargs):
"""Return a dictionary of kwargs that will be passed when setting up the Context.
This allows commands to pass in any specific arguments they define to the context.
Args:
options (argparse.Namespace): arguments that have been passed via the command line
Returns:
dict: Dictionary that will be passed to Context initializer as kwargs.
"""
return {}
11 changes: 8 additions & 3 deletions stacker/commands/stacker/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import copy

from .build import Build
from .destroy import Destroy
from ..base import BaseCommand
from ...context import Context
from ...providers import aws
Expand All @@ -9,7 +10,7 @@
class Stacker(BaseCommand):

name = 'stacker'
subcommands = (Build,)
subcommands = (Build, Destroy)

def configure(self, options, **kwargs):
super(Stacker, self).configure(options, **kwargs)
Expand All @@ -18,7 +19,11 @@ def configure(self, options, **kwargs):
namespace=options.namespace,
environment=options.environment,
parameters=copy.deepcopy(options.parameters),
stack_names=options.stacks,
force_stacks=options.force,
# We use
# set_default(get_context_kwargs=subcommand.get_context_kwargs) so
# the subcommand can provide any specific kwargs to the Context
# that it wants. We need to pass down the options so it can
# reference any arguments it defined.
**options.get_context_kwargs(options)
)
options.context.load_config(options.config.read())
6 changes: 0 additions & 6 deletions stacker/commands/stacker/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,12 +77,6 @@ def add_arguments(self, parser):
parser.add_argument("-v", "--verbose", action="count", default=0,
help="Increase output verbosity. May be specified "
"up to twice.")
parser.add_argument("--stacks", action="append",
metavar="STACKNAME", type=str,
help="Only work on the stacks given. Can be "
"specified more than once. If not specified "
"then stacker will work on all stacks in the "
"config file.")
parser.add_argument("namespace",
help="The namespace for the stack collection. "
"This will be used as the prefix to the "
Expand Down
9 changes: 9 additions & 0 deletions stacker/commands/stacker/build.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,17 @@ def add_arguments(self, parser):
help="If a stackname is provided to --force, it "
"will be updated, even if it is locked in "
"the config.")
parser.add_argument("--stacks", action="append",
metavar="STACKNAME", type=str,
help="Only work on the stacks given. Can be "
"specified more than once. If not specified "
"then stacker will work on all stacks in the "
"config file.")

def run(self, options, **kwargs):
super(Build, self).run(options, **kwargs)
action = build.Action(options.context, provider=options.provider)
action.execute(outline=options.outline)

def get_context_kwargs(self, options, **kwargs):
return {'stack_names': options.stacks, 'force_stacks': options.force}
36 changes: 36 additions & 0 deletions stacker/commands/stacker/destroy.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
"""Destroys CloudFormation stacks based on the given config.
Stacker will determine the order in which stacks should be destroyed based on
any manual requirements they specify or output values they rely on from other
stacks.
"""
from .base import StackerCommand
from ...actions import destroy


class Destroy(StackerCommand):

name = "destroy"
description = __doc__

def add_arguments(self, parser):
super(Destroy, self).add_arguments(parser)
parser.add_argument('-f', '--force', action='store_true',
help="Whehter or not you want to go through "
" with destroying the stacks")

def run(self, options, **kwargs):
super(Destroy, self).run(options, **kwargs)
action = destroy.Action(options.context, provider=options.provider)
stack_names = [' - %s' % (s.fqn,) for s in options.context.get_stacks()]
message = '\nAre you sure you want to destroy the following stacks:\n%s\n\n(yes/no): ' % (
'\n'.join(stack_names),
)
force = False
if options.force:
confirm = raw_input(message)
force = confirm.lower() == 'yes'
if not force:
self.logger.info('Confirmation failed, printing ouline...')
action.execute(force=force)
10 changes: 9 additions & 1 deletion stacker/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,5 +64,13 @@ def get_stacks_dict(self):
return dict((stack.fqn, stack) for stack in self.get_stacks())

def get_fqn(self, name=None):
"""Return the fully qualified name of an object within this context."""
"""Return the fully qualified name of an object within this context.
If the name passed already appears to be a fully qualified name, it
will be returned with no further processing.
"""
if name and name.startswith(self._base_fqn + '-'):
return name

return '-'.join(filter(None, [self._base_fqn, name]))
7 changes: 6 additions & 1 deletion stacker/plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ def execute(self):

self._check_point()

def outline(self, level=logging.INFO):
def outline(self, level=logging.INFO, message=''):
"""Print an outline of the actions the plan is going to take.
The outline will represent the rough ordering of the steps that will be
Expand All @@ -185,6 +185,8 @@ def outline(self, level=logging.INFO):
Args:
level (Optional[int]): a valid log level that should be used to log
the outline
message (Optional[str]): a message that will be logged to
the user after the outline has been logged.
"""
steps = 1
logger.log(level, 'Plan "%s":', self.description)
Expand All @@ -202,6 +204,9 @@ def outline(self, level=logging.INFO):
step.status = COMPLETE
steps += 1

if message:
logger.log(level, message)

def _check_point(self):
logger.info('Plan Status:')
for step_name, step in self.iteritems():
Expand Down
2 changes: 1 addition & 1 deletion stacker/stack.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ def __repr__(self):

@property
def requires(self):
requires = set(self.definition.get('requires', []))
requires = set([self.context.get_fqn(r) for r in self.definition.get('requires', [])])
# Auto add dependencies when parameters reference the Ouptuts of
# another stack.
for value in self.parameters.values():
Expand Down
12 changes: 7 additions & 5 deletions stacker/tests/actions/test_build.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,11 +132,11 @@ def test_get_dependencies(self):
dependencies = build_action._get_dependencies()
self.assertEqual(
dependencies[context.get_fqn('bastion')],
set(map(context.get_fqn, ['vpc'])),
set([context.get_fqn('vpc')]),
)
self.assertEqual(
dependencies[context.get_fqn('db')],
set(map(context.get_fqn, ['vpc', 'bastion'])),
set([context.get_fqn(s) for s in['vpc', 'bastion']]),
)
self.assertFalse(dependencies[context.get_fqn('other')])

Expand All @@ -147,15 +147,17 @@ def test_get_stack_execution_order(self):
execution_order = build_action.get_stack_execution_order(dependencies)
self.assertEqual(
execution_order,
map(context.get_fqn, ['other', 'vpc', 'bastion', 'db']),
[context.get_fqn(s) for s in ['other', 'vpc', 'bastion', 'db']],
)

def test_generate_plan(self):
context = self._get_context()
build_action = build.Action(context)
plan = build_action._generate_plan()
self.assertEqual(plan.keys(), map(context.get_fqn,
['other', 'vpc', 'bastion', 'db']))
self.assertEqual(
plan.keys(),
[context.get_fqn(s) for s in ['other', 'vpc', 'bastion', 'db']],
)

def test_dont_execute_plan_when_outline_specified(self):
context = self._get_context()
Expand Down

0 comments on commit 8a3594b

Please sign in to comment.