# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# Copyright 2014 Nextdoor.com, Inc
"""
:mod:`kingpin.actors.aws.cloudformation`
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
"""
import logging
from boto.exception import BotoServerError
from tornado import concurrent
from tornado import gen
from tornado import ioloop
from kingpin import utils
from kingpin.actors import exceptions
from kingpin.actors.aws import base
from kingpin.constants import REQUIRED
log = logging.getLogger(__name__)
__author__ = 'Matt Wise <matt@nextdoor.com>'
# This executor is used by the tornado.concurrent.run_on_executor()
# decorator. We would like this to be a class variable so its shared
# across RightScale objects, but we see testing IO errors when we
# do this.
EXECUTOR = concurrent.futures.ThreadPoolExecutor(10)
[docs]class InvalidTemplate(exceptions.UnrecoverableActorFailure):
"""An invalid CloudFormation template was supplied."""
[docs]class StackAlreadyExists(exceptions.RecoverableActorFailure):
"""The requested CloudFormation stack already exists."""
[docs]class StackNotFound(exceptions.RecoverableActorFailure):
"""The requested CloudFormation stack does not exist."""
# CloudFormation has over a dozen different 'stack states'... but for the
# purposes of these actors, we really only care about a few logical states.
# Here we map the raw states into logical states.
COMPLETE = ('CREATE_COMPLETE', 'UPDATE_COMPLETE')
DELETED = ('DELETE_COMPLETE', 'ROLLBACK_COMPLETE')
IN_PROGRESS = (
'CREATE_IN_PROGRESS', 'DELETE_IN_PROGRESS',
'ROLLBACK_IN_PROGRESS', 'UPDATE_COMPLETE_CLEANUP_IN_PROGRESS',
'UPDATE_IN_PROGRESS', 'UPDATE_ROLLBACK_COMPLETE_CLEANUP_IN_PROGRESS',
'UPDATE_ROLLBACK_IN_PROGRESS')
FAILED = (
'CREATE_FAILED', 'DELETE_FAILED', 'ROLLBACK_FAILED',
'UPDATE_ROLLBACK_FAILED')
[docs]class Create(CloudFormationBaseActor):
"""Creates a CloudFormation stack.
Creates a CloudFormation stack from scratch and waits until the stack is
fully built before exiting the actor.
**Options**
:capabilities:
A list of CF capabilities to add to the stack.
:disable_rollback:
Set to True to disable rollback of the stack if creation failed.
:name:
The name of the queue to create
:parameters:
A dictionary of key/value pairs used to fill in the parameters for the
CloudFormation template.
:region:
AWS region (or zone) string, like 'us-west-2'
:template:
String of path to CloudFormation template. Can either be in the form of a
local file path (ie, `./my_template.json`) or a URI (ie
`https://my_site.com/cf.json`).
:timeout_in_minutes:
The amount of time that can pass before the stack status becomes
CREATE_FAILED.
**Examples**
.. code-block:: json
{ "desc": "Create production backend stack",
"actor": "aws.cloudformation.Create",
"options": {
"capabilities": [ "CAPABILITY_IAM" ],
"disable_rollback": true,
"name": "%CF_NAME%",
"parameters": {
"test_param": "%TEST_PARAM_NAME%",
},
"region": "us-west-1",
"template": "/examples/cloudformation_test.json",
"timeout_in_minutes": 45,
}
}
**Dry Mode**
Validates the template, verifies that an existing stack with that name does
not exist. Does not create the stack.
"""
all_options = {
'capabilities': (list, [],
'The list of capabilities that you want to allow '
'in the stack'),
'disable_rollback': (bool, False,
'Set to `True` to disable rollback of the stack '
'if stack creation failed.'),
'name': (str, REQUIRED, 'Name of the stack'),
'parameters': (dict, {}, 'Parameters passed into the CF '
'template execution'),
'region': (str, REQUIRED, 'AWS region (or zone) name, like us-west-2'),
'template': (str, REQUIRED,
'Path to the AWS CloudFormation File. http(s)://, '
'file:///, absolute or relative file paths.'),
'timeout_in_minutes': (int, 60,
'The amount of time that can pass before the '
'stack status becomes CREATE_FAILED'),
}
def __init__(self, *args, **kwargs):
"""Initialize our object variables."""
super(Create, self).__init__(*args, **kwargs)
# Check if the supplied CF template is a local file. If it is, read it
# into memory.
(self._template_body, self._template_url) = self._get_template_body(
self.option('template'))
def _get_template_body(self, template):
"""Reads in a local template file and returns the contents.
If the template string supplied is a local file resource (has no
URI prefix), then this method will return the contents of the file.
Otherwise, returns None.
Args:
template: String with a reference to a template location.
Returns:
One tuple of:
(Contents of template file, None)
(None, URL of template)
Raises:
InvalidTemplate
"""
remote_types = ('http://', 'https://')
if template.startswith(remote_types):
return (None, template)
try:
# TODO: leverage self.readfile()
return (open(template, 'r').read(), None)
except IOError as e:
raise InvalidTemplate(e)
@gen.coroutine
def _validate_template(self):
"""Validates the CloudFormation template.
Raises:
InvalidTemplate
exceptions.InvalidCredentials
"""
if self._template_body is not None:
self.log.info('Validating template with AWS...')
else:
self.log.info('Validating template (%s) with AWS...' %
self._template_url)
try:
yield self.thread(
self.cf_conn.validate_template,
template_body=self._template_body,
template_url=self._template_url)
except BotoServerError as e:
msg = '%s: %s' % (e.error_code, e.message)
if e.status == 400:
raise InvalidTemplate(msg)
raise
@gen.coroutine
def _create_stack(self):
"""Executes the stack creation."""
# Create the stack, and get its ID.
self.log.info('Creating stack %s' % self.option('name'))
try:
stack_id = yield self.thread(
self.cf_conn.create_stack,
self.option('name'),
template_body=self._template_body,
template_url=self._template_url,
parameters=self.option('parameters').items(),
disable_rollback=self.option('disable_rollback'),
timeout_in_minutes=self.option('timeout_in_minutes'),
capabilities=self.option('capabilities'))
except BotoServerError as e:
msg = '%s: %s' % (e.error_code, e.message)
if e.status == 400:
raise CloudFormationError(msg)
raise
self.log.info('Stack %s created: %s' % (self.option('name'), stack_id))
raise gen.Return(stack_id)
@gen.coroutine
def _execute(self):
stack_name = self.option('name')
yield self._validate_template()
# If a stack already exists, we cannot re-create it. Raise a
# recoverable exception and let the end user decide whether this is bad
# or not.
exists = yield self._get_stack(stack_name)
if exists:
raise StackAlreadyExists('Stack %s already exists!' % stack_name)
# If we're in dry mode, exit at this point. We can't do anything
# further to validate that the creation process will work.
if self._dry:
self.log.info('Skipping CloudFormation Stack creation.')
raise gen.Return()
# Create the stack
yield self._create_stack()
# Now wait until the stack creation has finished
yield self._wait_until_state(COMPLETE)
raise gen.Return()
[docs]class Delete(CloudFormationBaseActor):
"""Deletes a CloudFormation stack
**Options**
:name:
The name of the queue to create
:region:
AWS region (or zone) string, like 'us-west-2'
**Examples**
.. code-block:: json
{ "desc": "Create production backend stack",
"actor": "aws.cloudformation.Create",
"options" {
"region": "us-west-1",
"name": "%CF_NAME%",
}
}
**Dry Mode**
Validates that the CF stack exists, but does not delete it.
"""
all_options = {
'name': (str, REQUIRED, 'Name of the stack'),
'region': (str, REQUIRED, 'AWS region (or zone) name, like us-west-2')
}
@gen.coroutine
def _delete_stack(self):
"""Executes the stack deletion."""
# Create the stack, and get its ID.
self.log.info('Deleting stack %s' % self.option('name'))
try:
ret = yield self.thread(
self.cf_conn.delete_stack, self.option('name'))
except BotoServerError as e:
msg = '%s: %s' % (e.error_code, e.message)
if e.status == 400:
raise CloudFormationError(msg)
raise
self.log.info('Stack %s delete requested: %s' %
(self.option('name'), ret))
raise gen.Return(ret)
@gen.coroutine
def _execute(self):
stack_name = self.option('name')
# If the stack doesn't exist, let the user know.
exists = yield self._get_stack(stack_name)
if not exists:
raise StackNotFound('Stack %s does not exist!' % stack_name)
# If we're in dry mode, exit at this point. We can't do anything
# further to validate that the creation process will work.
if self._dry:
self.log.info('Skipping CloudFormation Stack deletion.')
raise gen.Return()
# Delete
yield self._delete_stack()
# Now wait until the stack creation has finished
try:
yield self._wait_until_state(DELETED)
except StackNotFound:
# Pass here because a stack not found exception is totally
# reasonable since we're deleting the stack. Sometimes Amazon
# actually deletes the stack immediately, and othertimes it lists
# the stack as a 'deleted' state, but we still get that state back.
# Either case is fine.
pass
raise gen.Return()