class OpenStax::Aws::Stack

Constants

SHORT_CAPABILITIES

Attributes

absolute_template_path[R]
dry_run[R]
enable_termination_protection[R]
id[R]
name[R]
parameter_defaults[R]
region[R]
secrets_blocks[R]
tags[R]
volatile_parameters_block[R]

Public Class Methods

format_hash_as_stack_parameters(params={}) click to toggle source
# File lib/openstax/aws/stack.rb, line 322
def self.format_hash_as_stack_parameters(params={})
  params.map do |key, value|
    {
      parameter_key: key.to_s.split('_').collect(&:capitalize).join,
    }.tap do |hash|
      if value == :use_previous_value
        hash[:use_previous_value] = true
      else
        hash[:parameter_value] = value.to_s
      end
    end
  end
end
format_hash_as_tag_parameters(tags) click to toggle source
# File lib/openstax/aws/stack.rb, line 336
def self.format_hash_as_tag_parameters(tags)
  tags.map{|tag| {key: tag.key, value: tag.value}}
end
new(id: nil, name:, tags: {}, region:, enable_termination_protection: false, absolute_template_path: nil, capabilities: nil, parameter_defaults: {}, volatile_parameters_block: nil, secrets_blocks: [], secrets_context: nil, secrets_namespace: nil, shared_secrets_substitutions_block: nil, cycle_if_different_parameter: nil, dry_run: true) click to toggle source
# File lib/openstax/aws/stack.rb, line 11
def initialize(id: nil, name:, tags: {},
               region:, enable_termination_protection: false,
               absolute_template_path: nil,
               capabilities: nil, parameter_defaults: {},
               volatile_parameters_block: nil,
               secrets_blocks: [], secrets_context: nil, secrets_namespace: nil,
               shared_secrets_substitutions_block: nil,
               cycle_if_different_parameter: nil,
               dry_run: true)
  @id = id

  raise "Stack name must not be blank" if name.blank?
  @name = name

  raise "`tags` must be a hash" if !tags.is_a?(Hash)

  @tags = tags.map{|key, value| OpenStax::Aws::Tag.new(key, value)}

  @region = region || raise("region is not set for stack #{name}")
  @enable_termination_protection = enable_termination_protection

  @absolute_template_path = absolute_template_path

  set_capabilities(capabilities)
  @parameter_defaults = parameter_defaults.dup.freeze
  @volatile_parameters_block = volatile_parameters_block

  @secrets_blocks = [secrets_blocks].flatten.compact
  @secrets_context = secrets_context
  @secrets_namespace = secrets_namespace
  @shared_secrets_substitutions_block = shared_secrets_substitutions_block

  @cycle_if_different_parameter = (
    cycle_if_different_parameter ||
    OpenStax::Aws.configuration.default_cycle_if_different_parameter
  ).underscore.to_sym

  @dry_run = dry_run
end
query(regex: /.*/, regions: %w(us-east-1 us-east-2 us-west-1 us-west-2), active: true, reload: false) click to toggle source
# File lib/openstax/aws/stack.rb, line 353
def self.query(regex: /.*/, regions: %w(us-east-1 us-east-2 us-west-1 us-west-2), active: true, reload: false)
  stack_status_filter = active ? Status.active_status_texts : nil

  if reload
    @all_stacks = {}
  else
    @all_stacks ||= {}
  end

  # Memoize the query results to speed up subsequent queries
  @all_stacks[stack_status_filter + regions] ||= regions.map do |region|
    client = Aws::CloudFormation::Client.new(region: region)
    client.list_stacks(stack_status_filter: stack_status_filter).map do |response|
      response.stack_summaries.map do |summary|
        OpenStax::Aws.configuration.without_required_stack_tags do
          new(name: summary.stack_name, region: region)
        end
      end
    end
  end.flatten

  @all_stacks[stack_status_filter + regions].select{|stack| stack.name.match(regex)}
end

Public Instance Methods

apply_change_set(params: {}, wait: false) click to toggle source
# File lib/openstax/aws/stack.rb, line 172
def apply_change_set(params: {}, wait: false)
  logger.info("**** DRY RUN ****") if dry_run

  check_for_required_tags

  logger.info("Updating #{name} stack...")

  params = parameters_for_update(overrides: params)

  if defines_secrets?
    logger.info("Updating #{name} stack secrets...")

    secrets_changed = secrets(
      parameters: StackParameters.new(params: params, stack: self),
      for_create_or_update: true
    ).update

    if secrets_changed && template_parameter_keys.include?(@cycle_if_different_parameter)
      logger.info("Secrets changed, setting stack parameter to trigger server cycling")
      params[@cycle_if_different_parameter] = SecureRandom.hex(10)
    end
  end

  options = {
    stack_name: name,
    template_url: template.s3_url,
    capabilities: capabilities,
    parameters: self.class.format_hash_as_stack_parameters(params),
    change_set_name: "#{name}-#{Time.now.utc.strftime("%Y%m%d-%H%M%S")}",
    tags: self.class.format_hash_as_tag_parameters(@tags),
  }

  change_set = create_change_set(options)

  if change_set.created?
    resource_changes = change_set.resource_change_summaries

    logger.info("#{resource_changes.size} resource change(s)#{':' if !resource_changes.empty?}")
    resource_changes.each do |resource_change|
      logger.debug(resource_change)
    end

    if dry_run
      logger.info("Deleting change set (because this is a dry run)")
      change_set.delete
    else
      logger.info("Executing change set")
      change_set.execute
      reset_cached_remote_state
    end

    wait_for_update if wait
  end

  change_set
end
aws_stack() click to toggle source
# File lib/openstax/aws/stack.rb, line 377
def aws_stack
  ::Aws::CloudFormation::Stack.new(name: name, client: client)
end
capabilities() click to toggle source
# File lib/openstax/aws/stack.rb, line 309
def capabilities
  set_capabilities(default_capabilities) if @capabilities.nil?
  @capabilities
end
create(params: {}, wait: false, skip_if_exists: false) click to toggle source
# File lib/openstax/aws/stack.rb, line 62
def create(params: {}, wait: false, skip_if_exists: false)
  logger.info("**** DRY RUN ****") if dry_run

  check_for_required_tags

  if skip_if_exists && exists?
    logger.info("Skipping #{name} stack - exists...")
    return
  end

  params = parameter_defaults.merge(params)

  if defines_secrets?
    logger.info("Creating #{name} stack secrets...")
    secrets(parameters: params, for_create_or_update: true).create
  end

  options = {
    stack_name: name,
    template_url: template.s3_url,
    capabilities: capabilities,
    parameters: self.class.format_hash_as_stack_parameters(params),
    enable_termination_protection: enable_termination_protection,
    tags: self.class.format_hash_as_tag_parameters(@tags),
  }

  logger.info("Creating #{name} stack...")
  client.create_stack(options) if !dry_run

  wait_for_creation if wait
end
create_change_set(options) click to toggle source
# File lib/openstax/aws/stack.rb, line 168
def create_change_set(options)
  OpenStax::Aws::ChangeSet.new(client: client).create(options: options)
end
default_capabilities() click to toggle source
# File lib/openstax/aws/stack.rb, line 314
def default_capabilities
  if OpenStax::Aws.configuration.infer_stack_capabilities
    template.required_capabilities
  else
    []
  end
end
defines_secrets?() click to toggle source
# File lib/openstax/aws/stack.rb, line 349
def defines_secrets?
  !secrets_blocks.empty?
end
delete(retain_resources: [], wait: false) click to toggle source
# File lib/openstax/aws/stack.rb, line 229
def delete(retain_resources: [], wait: false)
  logger.info("**** DRY RUN ****") if dry_run

  if defines_secrets?
    logger.info("Deleting #{name} stack secrets...")
    secrets.delete
  end

  logger.info("Deleting #{name} stack...")

  if exists?
    client.delete_stack(stack_name: name, retain_resources: retain_resources) if !dry_run
  else
    logger.info("Cannot delete #{name} stack as it does not exist")
  end

  wait_for_deletion if wait
end
deployed_parameters() click to toggle source
# File lib/openstax/aws/stack.rb, line 135
def deployed_parameters
  begin
    @deployed_parameters ||= aws_stack.parameters.each_with_object({}) do |parameter, hash|
      hash[parameter.parameter_key.underscore.to_sym] = parameter.parameter_value
    end
  rescue Aws::CloudFormation::Errors::ValidationError => ee
    if ee.message =~ /Stack.*does not exist/
      {}
    else
      raise
    end
  end
end
events() click to toggle source
# File lib/openstax/aws/stack.rb, line 345
def events
  (aws_stack&.events || []).map{|aws_event| Event.new(aws_event)}
end
output_value(key:) click to toggle source
# File lib/openstax/aws/stack.rb, line 248
def output_value(key:)
  if dry_run
    "undefined-in-dry-run"
  else
    output = aws_stack.outputs.find {|output| output.output_key == key}
    raise "No output with key #{key} in stack #{name}" if output.nil?
    output.output_value
  end
end
parameters_for_update(overrides: {}) click to toggle source
# File lib/openstax/aws/stack.rb, line 94
def parameters_for_update(overrides: {})
  parameters = {}

  # Start populating the parameters hash by using `:use_previous_value` for any
  # parameter that is currently in the template that is also currently on the stack,
  # and using the defined default value for any other parameter.

  continuing_parameter_keys.each do |continuing_parameter_key|
    parameters[continuing_parameter_key] = :use_previous_value
  end

  new_parameter_keys.each do |new_parameter_key|
    parameters[new_parameter_key] = parameter_defaults[new_parameter_key]
  end

  # Volatile parameters can be changed outside of cloudformation updates.  Here
  # we get their current values by executing the block in the context of this
  # stack, and then we merge them in (overwriting any values already in the
  # parameters hash).

  parameters.merge!(volatile_parameters)

  # Lastly, we merge in the overrides hash (e.g. things purposefully set
  # by an outside caller) -- they take precendence over all previous values.

  parameters.merge!(overrides)

  # Leave out nil-valued parameters as they are not valid (and likely not
  # intentional)

  parameters.compact
end
resource(logical_id) click to toggle source
# File lib/openstax/aws/stack.rb, line 290
def resource(logical_id)
  stack_resource = aws_stack.resource(logical_id)

  case stack_resource.resource_type
  when "AWS::AutoScaling::AutoScalingGroup"
    name = stack_resource.physical_resource_id
    client = Aws::AutoScaling::Client.new(region: region)
    Aws::AutoScaling::AutoScalingGroup.new(name: name, client: client)
  when "AWS::RDS::DBInstance"
    db_instance_identifier = stack_resource.physical_resource_id
    OpenStax::Aws::RdsInstance.new(db_instance_identifier: db_instance_identifier, region: region)
  when "AWS::MSK::Cluster"
    msk_cluster_arn = stack_resource.physical_resource_id
    OpenStax::Aws::MskCluster.new(cluster_arn: msk_cluster_arn, region: region)
  else
    raise "'#{stack_resource.resource_type}' is not yet implemented in `Stack#resource`"
  end
end
secrets(parameters: {}, for_create_or_update: false) click to toggle source
# File lib/openstax/aws/stack.rb, line 149
def secrets(parameters: {}, for_create_or_update: false)
  SecretsSet.new(
    secrets_blocks.map do |secrets_block|
      secrets_factory = SecretsFactory.new(
        region: region,
        namespace: @secrets_namespace,
        context: @secrets_context,
        dry_run: dry_run,
        for_create_or_update: for_create_or_update,
        shared_substitutions_block: @shared_secrets_substitutions_block
      )

      secrets_factory.namespace(id)
      secrets_factory.instance_exec parameters, &secrets_block
      secrets_factory.instance
    end
  )
end
status(reload: false) click to toggle source
# File lib/openstax/aws/stack.rb, line 340
def status(reload: false)
  @status = nil if reload
  @status ||= Status.new(self)
end
template() click to toggle source
# File lib/openstax/aws/stack.rb, line 51
def template
  @template ||= begin
    if absolute_template_path.present?
      OpenStax::Aws::Template.from_absolute_file_path(absolute_template_path)
    else
      body = client.get_template({stack_name: name}).template_body
      OpenStax::Aws::Template.from_body(body)
    end
  end
end
volatile_parameters() click to toggle source
# File lib/openstax/aws/stack.rb, line 127
def volatile_parameters
  return {} if volatile_parameters_block.nil?

  volatile_parameters_factory = StackFactory::VolatileParametersFactory.new(self)
  volatile_parameters_factory.instance_eval(&volatile_parameters_block)
  volatile_parameters_factory.attributes
end
wait_for_creation() click to toggle source
# File lib/openstax/aws/stack.rb, line 258
def wait_for_creation
  if !dry_run
    return if !creating?
    wait_for_stack_event(waiter_class: Aws::CloudFormation::Waiters::StackCreateComplete,
                         word: "created")
  end
end
wait_for_deletion() click to toggle source
# File lib/openstax/aws/stack.rb, line 274
def wait_for_deletion
  if !dry_run
    begin
      return if !deleting?
      wait_for_stack_event(waiter_class: Aws::CloudFormation::Waiters::StackDeleteComplete,
                           word: "deleted")
    rescue Aws::CloudFormation::Errors::ValidationError => ee
      if ee.message =~ /Stack.*does not exist/
        logger.warn("Waiting for stack #{name} to be deleted failed because it does not exist")
      else
        raise
      end
    end
  end
end
wait_for_update() click to toggle source
# File lib/openstax/aws/stack.rb, line 266
def wait_for_update
  if !dry_run
    return if !updating? # if not updating, waiting for an updated message will thrash until timeout
    wait_for_stack_event(waiter_class: Aws::CloudFormation::Waiters::StackUpdateComplete,
                         word: "updated")
  end
end

Protected Instance Methods

check_for_required_tags() click to toggle source
# File lib/openstax/aws/stack.rb, line 454
def check_for_required_tags
  OpenStax::Aws.configuration.required_stack_tags.each do |required_tag|
    tag = tag(required_tag)
    if tag.nil? || tag.value.blank?
      raise "The '#{required_tag}' tag is required on the '#{name}' stack but is blank or missing"
    end
  end

end
client() click to toggle source
# File lib/openstax/aws/stack.rb, line 446
def client
  @client ||= ::Aws::CloudFormation::Client.new(region: region)
end
continuing_parameter_keys() click to toggle source
# File lib/openstax/aws/stack.rb, line 430
def continuing_parameter_keys
  template_parameter_keys & deployed_parameters.keys
end
logger() click to toggle source
# File lib/openstax/aws/stack.rb, line 442
def logger
  OpenStax::Aws.configuration.logger
end
new_parameter_keys() click to toggle source
# File lib/openstax/aws/stack.rb, line 434
def new_parameter_keys
  template_parameter_keys - continuing_parameter_keys
end
reset_cached_remote_state() click to toggle source
# File lib/openstax/aws/stack.rb, line 438
def reset_cached_remote_state
  @deployed_parameters = nil
end
set_capabilities(capabilities) click to toggle source
# File lib/openstax/aws/stack.rb, line 408
def set_capabilities(capabilities)
  return if capabilities.nil?

  capabilities = [capabilities].flatten.compact

  valid_capabilities = SHORT_CAPABILITIES.keys + SHORT_CAPABILITIES.values

  capabilities.each do |capability|
    if !valid_capabilities.include?(capability)
      raise "Capabilities must be in #{valid_capabilities}"
    end
  end

  @capabilities = capabilities.map do |capability|
    SHORT_CAPABILITIES[capability] || capability
  end.freeze
end
tag(name) click to toggle source
# File lib/openstax/aws/stack.rb, line 450
def tag(name)
  tags.select{|tag| tag.key == name}.first
end
template_parameter_keys() click to toggle source
# File lib/openstax/aws/stack.rb, line 426
def template_parameter_keys
  @tpks ||= template.parameter_names.map(&:underscore).map(&:to_sym)
end
wait_for_stack_event(waiter_class:, word:) click to toggle source
# File lib/openstax/aws/stack.rb, line 383
def wait_for_stack_event(waiter_class:, word:)
  wait_message = OpenStax::Aws::WaitMessage.new(
    message: "Waiting for #{name} stack to be #{word}"
  )

  begin
    waiter_class.new(
      client: client,
      before_attempt: ->(*) { wait_message.say_it },
      delay: OpenStax::Aws.configuration.stack_waiter_delay,
      max_attempts: OpenStax::Aws.configuration.stack_waiter_max_attempts
    ).wait(stack_name: name)
  rescue Aws::Waiters::Errors::WaiterFailed => error
    logger.error("Waiting failed: #{error.message}")
    raise
  end
  logger.info "#{name} has been #{word}!"
end