class Convection::Control::Stack

The Stack class provides a state wrapper for CloudFormation Stacks. It tracks the state of the managed stack, and creates/updates accordingly. Stack is also region-aware, and can be used within a template to define resources that depend upon availability-zones or other region-specific neuances that cannot be represented as maps or require iteration.

Monkey patch functions defined on Stack for use during terraform export.

Constants

CREATE_COMPLETE

Represents a stack that has successfully been converged.

CREATE_FAILED

Represents a stack that has not successfully been converged.

CREATE_IN_PROGRESS

Represents a stack that is currently being converged for the first time.

DELETE_COMPLETE

Represents a stack that has successfully been deleted.

DELETE_FAILED

Represents a stack that has not successfully been deleted.

DELETE_IN_PROGRESS

Represents a stack that is currently being deleted.

NOT_CREATED

Represents a stack that has not been created. The default state for a convection stack before getting its status.

ROLLBACK_COMPLETE

Represents a stack that has successfully been rolled back.

ROLLBACK_FAILED

Represents a stack that has not successfully been rolled back.

ROLLBACK_IN_PROGRESS

Represents a stack that is currently being rolled back.

TASK_COMPLETE

Represents a stack task being completed.

TASK_FAILED

Represents a stack task having failed.

TASK_IN_PROGRESS

Represents a stack task that is currently in progress.

UPDATE_COMPLETE

Represents a stack that has successfully been updated (re-converged).

UPDATE_COMPLETE_CLEANUP_IN_PROGRESS

Represents a stack that is currently performing post-update cleanup.

UPDATE_FAILED

Represents a stack that has not successfully been updated.

UPDATE_IN_PROGRESS

Represents a stack that is currently being updated (re-converged).

UPDATE_ROLLBACK_COMPLETE

Represents a stack that has successfully rolled back an update (re-converge).

UPDATE_ROLLBACK_COMPLETE_CLEANUP_IN_PROGRESS

Represents a stack that is currently performing post-update-rollback cleanup.

UPDATE_ROLLBACK_FAILED

Represents a stack that has successfully been rolled back after an update.

UPDATE_ROLLBACK_IN_PROGRESS

Represents a stack that is currently performing a update rollback.

Attributes

_original_cloud[RW]
_original_region[RW]

AWS-SDK

attribute_mapping_values[R]
attributes[R]
capabilities[R]
cloud[RW]
credentials[RW]
current_template[R]
errors[R]
exclude_availability_zones[RW]
exist[R]

@return [Boolean] whether the stack exists and has a status

other than DELETED.
exist?[R]

@return [Boolean] whether the stack exists and has a status

other than DELETED.
id[R]
name[R]
on_failure[RW]
options[R]
outputs[R]
parameters[R]
region[RW]

AWS-SDK

resources[R]
status[R]

@return [String] CloudFormation Stack status

tags[R]
tasks[R]
template[RW]

Public Class Methods

new(name, template, options = {}, &block) click to toggle source

@param name [String] the name of the CloudFormation Stack @param template [Convection::Model::Template] a wrapper of the

CloudFormation template (can be rendered into CF JSON)

@param options [Hash] an options hash to pass in advanced

configuration options. Undocumented options will be passed
directly to CloudFormation's #create_stack or #update_stack.

@option options [String] :region AWS region, format us-east-1. Default us-east-1 @option options [Hash] :credentials optional instance of `Aws::Credentials` @option options [Hash] :parameters CloudFormation Stack parameters @option options [String] :tags CloudFormation Stack tags @option options [String] :on_failure the create failure action. Default: `DELETE` @option options [Array<String>] :capabilities A list of capabilities (such as `CAPABILITY_IAM`).

See also {http://docs.aws.amazon.com/sdkforruby/api/Aws/CloudFormation/Client.html#create_stack-instance_method Aws::CloudFormation::Client#create_stack}
# File lib/convection/control/stack.rb, line 103
def initialize(name, template, options = {}, &block)
  @name = name
  @template = template.clone(self)
  @errors = []

  @cloud = options.delete(:cloud)
  @cloud_name = options.delete(:cloud_name)
  @region = options.delete(:region) { |_| 'us-east-1' }
  @exclude_availability_zones = options.delete(:exclude_availability_zones) { |_| [] } # Default empty Array
  @credentials = options.delete(:credentials)
  @parameters = options.delete(:parameters) { |_| {} } # Default empty hash
  @tags = options.delete(:tags) { |_| {} } # Default empty hash
  options.delete(:disable_rollback) # There can be only one...
  @on_failure = options.delete(:on_failure) { |_| 'DELETE' }
  @capabilities = options.delete(:capabilities) { |_| %w(CAPABILITY_IAM CAPABILITY_NAMED_IAM) }

  @attributes = options.delete(:attributes) { |_| Model::Attributes.new }
  @options = options
  @retry_limit = options[:retry_limit] || 7

  client_options = {}.tap do |opt|
    opt[:region] = @region
    opt[:credentials] = @credentials unless @credentials.nil?
    opt[:retry_limit] = @retry_limit
  end
  @ec2_client = Aws::EC2::Client.new(client_options)
  @cf_client = Aws::CloudFormation::Client.new(client_options)

  ## Remote state
  @exist = false
  @status = NOT_CREATED
  @id = nil
  @outputs = {}
  @resources = {}
  @tasks = { after_create: [], after_delete: [], after_update: [], before_create: [], before_delete: [], before_update: [] }
  instance_exec(&block) if block
  @current_template = {}
  @last_event_seen = nil

  # First pass evaluation of stack
  # This is important because it:
  #   * Catches syntax errors before starting a converge
  #   * Builds a list of all resources that allows stacks early in
  #     the dependency tree to know about later stacks.  Some
  #     clouds use this, for example, to create security groups early
  #     in the dependency tree to avoid the chicken-and-egg problem.
  @template.execute
rescue Aws::Errors::ServiceError => e
  @errors << e
end

Public Instance Methods

[](key) click to toggle source

@see Convection::Model::Attributes#get

# File lib/convection/control/stack.rb, line 189
def [](key)
  @attributes.get(name, key)
end
[]=(key, value) click to toggle source

@see Convection::Model::Attributes#set

# File lib/convection/control/stack.rb, line 194
def []=(key, value)
  @attributes.set(name, key, value)
end
after_create_task(task) click to toggle source

Register a given task to run after creation of a stack.

# File lib/convection/control/stack.rb, line 462
def after_create_task(task)
  @tasks[:after_create] << task
end
after_delete_task(task) click to toggle source

Register a given task to run after deletion of a stack.

# File lib/convection/control/stack.rb, line 467
def after_delete_task(task)
  @tasks[:after_delete] << task
end
after_update_task(task) click to toggle source

Register a given task to run after an update of a stack.

# File lib/convection/control/stack.rb, line 472
def after_update_task(task)
  @tasks[:after_update] << task
end
apply(retain: false, &block) click to toggle source

Render the CloudFormation template and apply the Stack using the CloudFormation client.

@param block [Proc] a configuration block to pass any

{Convection::Model::Event}s to.
# File lib/convection/control/stack.rb, line 324
def apply(retain: false, &block)
  request_options = @options.clone.tap do |o|
    o[:template_body] = to_json(retain: retain)
    o[:parameters] = cf_parameters
    o[:capabilities] = capabilities
  end

  # Get the state of existence before creation
  existing_stack = exist?
  if existing_stack
    if diff(retain: retain).empty? ## No Changes. Just get resources and move on
      block.call(Model::Event.new(:complete, "Stack #{ name } has no changes", :info)) if block
      get_status
      return
    elsif !resource_changes? && resource_dependent_changes?
      message = "Stack #{ name } has no convergable changes (you must update Resources to update Conditions, Metadata, or Outputs)"
      block.call(Model::Event.new(UPDATE_FAILED, message, :warn)) if block
      get_status
      return
    end

    ## Execute before update tasks
    @tasks[:before_update].delete_if do |task|
      run_task(:before_update, task, &block)
    end

    ## Update
    @cf_client.update_stack(request_options.tap do |o|
      o[:stack_name] = id
    end)
  else
    ## Execute before create tasks
    @tasks[:before_create].delete_if do |task|
      run_task(:before_create, task, &block)
    end

    ## Create
    @cf_client.create_stack(request_options.tap do |o|
      o[:stack_name] = cloud_name

      o[:tags] = cf_tags
      o[:on_failure] = on_failure
    end)

    get_status(cloud_name) # Get ID of new stack
  end

  watch(&block) if block # Block execution on stack status

  ## Execute after create tasks
  after_task_type = existing_stack ? :after_update : :after_create
  @tasks[after_task_type].delete_if do |task|
    run_task(after_task_type, task, &block)
  end
rescue Aws::Errors::ServiceError => e
  @errors << e
end
availability_zones(&block) click to toggle source

@param block [Proc] a block to pass each availability zone

(with its index)

@return [Array<String>] the list of availability zones found by

the call to ec2 client's describe availability zones
# File lib/convection/control/stack.rb, line 434
def availability_zones(&block)
  @availability_zones ||=
    @ec2_client.describe_availability_zones.availability_zones.map(&:zone_name)

  unless @exclude_availability_zones.empty?
    @availability_zones.reject! { |az| @exclude_availability_zones.include?(az) }
  end
  if @availability_zones.empty? && block
    fail 'There are no AvailabilityZones, check exclude_availability_zones in the Cloudfile.'
  end
  @availability_zones.sort!

  @availability_zones.each_with_index(&block) if block
  @availability_zones
end
before_create_task(task) click to toggle source

Register a given task to run before creation of a stack.

# File lib/convection/control/stack.rb, line 477
def before_create_task(task)
  @tasks[:before_create] << task
end
before_delete_task(task) click to toggle source

Register a given task to run before deletion of a stack.

# File lib/convection/control/stack.rb, line 482
def before_delete_task(task)
  @tasks[:before_delete] << task
end
before_update_task(task) click to toggle source

Register a given task to run before an update of a stack.

# File lib/convection/control/stack.rb, line 487
def before_update_task(task)
  @tasks[:before_update] << task
end
cloud_name() click to toggle source
# File lib/convection/control/stack.rb, line 169
def cloud_name
  return @cloud_name unless @cloud_name.nil?
  return name if cloud.nil?
  "#{ cloud }-#{ name }"
end
complete?() click to toggle source

@return [Boolean] whether the CloudFormation Stack is in one of

the several *_COMPLETE states.
# File lib/convection/control/stack.rb, line 223
def complete?
  [CREATE_COMPLETE, ROLLBACK_COMPLETE, UPDATE_COMPLETE, UPDATE_ROLLBACK_COMPLETE].include?(status)
end
credential_error?() click to toggle source

@return [Boolean] whether a credential error occurred is the reason

accessing the CloudFormation stack failed.
# File lib/convection/control/stack.rb, line 229
def credential_error?
  error? && errors.all? { |e| e.class == Aws::EC2::Errors::RequestExpired }
end
delete(&block) click to toggle source

Delete the CloudFormation Stack using the CloudFormation client.

@param block [Proc] a configuration block to pass any

{Convection::Model::Event}s to.
# File lib/convection/control/stack.rb, line 386
def delete(&block)
  ## Execute before delete tasks
  @tasks[:before_delete].delete_if do |task|
    run_task(:before_delete, task, &block)
  end

  @cf_client.delete_stack(
    :stack_name => id
  )

  ## Block execution on stack status
  watch(&block) if block

  get_status

  ## Execute after delete tasks
  @tasks[:after_delete].delete_if do |task|
    run_task(:after_delete, task, &block)
  end
rescue Aws::Errors::ServiceError => e
  @errors << e
end
delete_complete?() click to toggle source

@return [Boolean] whether the CloudFormation Stack is in one of

the several *_COMPLETE states.
# File lib/convection/control/stack.rb, line 235
def delete_complete?
  DELETE_COMPLETE == status
end
delete_success?() click to toggle source

@return [Boolean] whether the Stack state is now {#delete_complete?} (with no errors present).

# File lib/convection/control/stack.rb, line 240
def delete_success?
  !error? && delete_complete?
end
diff(retain: false) click to toggle source

@return [Hash] a set of differences between the current

template (in CloudFormation) and the state of the rendered
template (what *would* be converged).

@see Convection::Model::Template#diff

# File lib/convection/control/stack.rb, line 281
def diff(retain: false)
  @template.diff(@current_template, retain: retain)
end
error?() click to toggle source

@return [Boolean] whether any errors occurred modifying the

stack.
# File lib/convection/control/stack.rb, line 252
def error?
  !errors.empty?
end
fail?() click to toggle source

@return [Boolean] whether the CloudFormation Stack is in one of

the several *_FAILED states.
# File lib/convection/control/stack.rb, line 246
def fail?
  [CREATE_FAILED, ROLLBACK_FAILED, DELETE_FAILED, UPDATE_ROLLBACK_FAILED].include?(status)
end
fetch(*args) click to toggle source

@see Convection::Model::Attributes#fetch

# File lib/convection/control/stack.rb, line 199
def fetch(*args)
  @attributes.fetch(*args)
end
get(*args) click to toggle source

@see Convection::Model::Attributes#get

# File lib/convection/control/stack.rb, line 204
def get(*args)
  @attributes.get(*args)
end
in_progress?() click to toggle source

@return [Boolean] whether or not the CloudFormation Stack is in

one of several *IN_PROGRESS states.
# File lib/convection/control/stack.rb, line 214
def in_progress?
  [CREATE_IN_PROGRESS, ROLLBACK_IN_PROGRESS, DELETE_IN_PROGRESS,
   UPDATE_IN_PROGRESS, UPDATE_COMPLETE_CLEANUP_IN_PROGRESS,
   UPDATE_ROLLBACK_IN_PROGRESS,
   UPDATE_ROLLBACK_COMPLETE_CLEANUP_IN_PROGRESS].include?(status)
end
include?(stack, key = nil) click to toggle source

@overload include?(key)

@param key [String] the name of the attribute to find

@overload include?(stack_name, key)

@param stack_name [String] the name of the stack to check within
@param key [String] the name of the attribute to find

@return [Boolean] whether the stack includes the specified key.

# File lib/convection/control/stack.rb, line 183
def include?(stack, key = nil)
  return @attributes.include?(name, stack) if key.nil?
  @attributes.include?(stack, key)
end
load_template_info() click to toggle source
# File lib/convection/control/stack.rb, line 160
def load_template_info
  get_resources
  get_template
  resource_attributes
  get_events(1) # Get the latest page of events (Set @last_event_seen before starting)
rescue Aws::Errors::ServiceError => e
  @errors << e
end
render() click to toggle source

@see Convection::Model::Template#render

# File lib/convection/control/stack.rb, line 266
def render
  @template.render
end
resource_changes?() click to toggle source

@return [Boolean] whether the Resources section of the rendered

template has any changes compared to the current template (in
CloudFormation).
# File lib/convection/control/stack.rb, line 288
def resource_changes?
  ours = { 'Resources' => @template.all_resources.map(&:render) }
  thiers = { 'Resources' => @current_template['Resources'] }

  ours.diff(thiers).any?
end
resource_dependent_changes?() click to toggle source

@return [Boolean] whether the any template sections dependent

on the Resources section of the rendered template has any
changes compared to the current template (in CloudFormation).
For example Conditions, Metadata, and Outputs depend on
changes to resources to be able to converge. See also
{https://github.com/rapid7/convection/issues/140
rapid7/convection#140}.
# File lib/convection/control/stack.rb, line 302
def resource_dependent_changes?
  ours = {
    'Conditions' => @template.conditions.map(&:render),
    'Outputs' => @template.outputs.map(&:render)
  }
  theirs = {
    'Conditions' => @current_template['Conditions'],
    'Outputs' => @current_template['Outputs']
  }

  ours.diff(theirs).any?
end
success?() click to toggle source

@return [Boolean] whether the Stack state is now {#complete?} (with no errors present).

# File lib/convection/control/stack.rb, line 257
def success?
  !error? && complete?
end
template_status() click to toggle source
# File lib/convection/control/stack.rb, line 154
def template_status
  get_status(cloud_name)
rescue Aws::Errors::ServiceError => e
  @errors << e
end
to_json(pretty = false) click to toggle source

@param pretty [Boolean] whether to to pretty-print the JSON output @return the renedered CloudFormation Template JSON. @see Convection::Model::Template#to_json

# File lib/convection/control/stack.rb, line 273
def to_json(pretty = false)
  @template.to_json(nil, pretty)
end
validate() click to toggle source

Validates a rendered template against the CloudFormation API.

@raise unless the validation was successful

# File lib/convection/control/stack.rb, line 453
def validate
  result = @cf_client.validate_template(:template_body => template.to_json)
  fail result.context.http_response.inspect unless result.successful?
  puts "\nTemplate validated successfully"
end
watch(poll = 2, &block) click to toggle source

Loops through current events until the CloudFormation Stack has finished being modified.

# File lib/convection/control/stack.rb, line 413
def watch(poll = 2, &block)
  get_status

  loop do
    get_events.reverse_each do |event|
      block.call(Model::Event.from_cf(event))
    end if block

    break unless in_progress?

    sleep poll
    get_status
  end
rescue Aws::Errors::ServiceError => e
  @errors << e
end

Private Instance Methods

cf_parameters() click to toggle source
# File lib/convection/control/stack.rb, line 587
def cf_parameters
  parameters.map do |p|
    {
      :parameter_key => p[0].to_s,
      :parameter_value => p[1].to_s,
      :use_previous_value => false
    }
  end
end
cf_tags() click to toggle source
# File lib/convection/control/stack.rb, line 597
def cf_tags
  tags.map do |p|
    {
      :key => p[0].to_s,
      :value => p[1].to_s
    }
  end
end
get_events(pages = nil, stack_name = id) click to toggle source

Fetch new stack events

# File lib/convection/control/stack.rb, line 538
def get_events(pages = nil, stack_name = id)
  return [] unless exist?

  [].tap do |collection|
    @cf_client.describe_stack_events(:stack_name => stack_name).each do |page|
      pages -= 1 unless pages.nil?

      page.stack_events.each do |event|
        if @last_event_seen == event.event_id
          pages = 0 # Break page loop
          break
        end

        collection << event
      end

      # rubocop:disable Style/NumericPredicate
      break if pages == 0
      # rubocop:enable Style/NumericPredicate
    end

    @last_event_seen = collection.first.event_id unless collection.empty?
  end
rescue Aws::CloudFormation::Errors::ValidationError # Stack does not exist
end
get_resources() click to toggle source

Fetch current resources

# File lib/convection/control/stack.rb, line 519
def get_resources
  @resources = {}.tap do |collection|
    @cf_client.list_stack_resources(:stack_name => @id).each do |page|
      page.stack_resource_summaries.each do |resource|
        collection[resource[:logical_resource_id]] = resource
      end
    end
  end
rescue Aws::CloudFormation::Errors::ValidationError # Stack does not exist
  @resources = {}
end
get_status(stack_name = id) click to toggle source

@!endgroup

# File lib/convection/control/stack.rb, line 495
def get_status(stack_name = id)
  cf_stack = @cf_client.describe_stacks(:stack_name => stack_name).stacks.first

  @id = cf_stack.stack_id
  @status = cf_stack.stack_status
  @exist = true

  ## Parse outputs
  @outputs = {}.tap do |collection|
    cf_stack.outputs.each do |output|
      collection[output[:output_key].to_s] = (JSON.parse(output[:output_value]) rescue output[:output_value])
    end
  end

  ## Add outputs to attribute set
  @attributes.load_outputs(self)
rescue Aws::CloudFormation::Errors::ValidationError # Stack does not exist
  @exist = false
  @status = NOT_CREATED
  @id = nil
  @outputs = {}
end
get_template() click to toggle source
# File lib/convection/control/stack.rb, line 531
def get_template
  @current_template = JSON.parse(@cf_client.get_template(:stack_name => id).template_body)
rescue Aws::CloudFormation::Errors::ValidationError # Stack does not exist
  @current_template = {}
end
resource_attributes() click to toggle source

TODO No. This will become unnecessary as current_state is fleshed out

# File lib/convection/control/stack.rb, line 565
def resource_attributes
  @attribute_mapping_values = {}

  @resources.each do |logical, resource|
    next unless @template.attribute_mappings.include?(logical)

    attribute_map = @template.attribute_mappings[logical]
    case attribute_map[:type].to_sym
    when :string
      @attribute_mapping_values[attribute_map[:name]] = resource[:physical_resource_id]
    when :array
      @attribute_mapping_values[attribute_map[:name]] = [] unless @attribute_mapping_values[attribute_map[:name]].is_a?(Array)
      @attribute_mapping_values[attribute_map[:name]].push(resource[:physical_resource_id])
    else
      fail TypeError, "Attribute Mapping must be defined with type `string` or `array`, not #{ type }"
    end
  end

  ## Add mapped resource IDs to attributes
  @attributes.load_resources(self)
end
run_task(phase, task, &block) click to toggle source
# File lib/convection/control/stack.rb, line 606
def run_task(phase, task, &block)
  phase = phase.to_s.split.join(' ')
  block.call(Model::Event.new(TASK_IN_PROGRESS, "Task (#{phase}) #{task} in progress for stack #{name}.", :info)) if block

  task.call(self)
  return task.success? unless block

  if task.success?
    block.call(Model::Event.new(TASK_COMPLETE, "Task (#{phase}) #{task} successfully completed for stack #{name}.", :info))
    true
  else
    block.call(Model::Event.new(TASK_FAILED, "Task (#{phase}) #{task} failed to complete for stack #{name}.", :error))
    false
  end
end