module CuffSert

TODO:

TODO: Animate in-progress states

Constants

ACTION_ORDER
BAD_STATES
FINAL_STATES
GOOD_STATES
INPROGRESS_STATES
STACKNAME_RE
TIMEOUT
VERSION

Public Class Methods

as_cloudformation_args(meta) click to toggle source
# File lib/cuffsert/cfarguments.rb, line 12
def self.as_cloudformation_args(meta)
  cfargs = {
    :stack_name => meta.stackname,
    :capabilities => %w[
      CAPABILITY_AUTO_EXPAND
      CAPABILITY_IAM
      CAPABILITY_NAMED_IAM
    ],
  }

  unless meta.parameters.empty?
    cfargs[:parameters] = meta.parameters.map do |k, v|
      if v.nil?
        {:parameter_key => k, :use_previous_value => true}
      else
        {:parameter_key => k, :parameter_value => v.to_s}
      end
    end
  end

  unless meta.tags.empty?
    cfargs[:tags] = meta.tags.map do |k, v|
      {:key => k, :value => v.to_s}
    end
  end

  if meta.stack_uri
    cfargs.merge!(self.template_parameters(meta))
  end
  cfargs
end
as_create_stack_args(meta) click to toggle source
# File lib/cuffsert/cfarguments.rb, line 44
def self.as_create_stack_args(meta)
  no_value = meta.parameters.select {|_, v| v.nil? }.keys
  raise "Supply value for #{no_value.join(', ')}" unless no_value.empty?

  cfargs = self.as_cloudformation_args(meta)
  cfargs[:timeout_in_minutes] = TIMEOUT
  cfargs[:on_failure] = 'DELETE'
  cfargs
end
as_delete_stack_args(stack) click to toggle source
# File lib/cuffsert/cfarguments.rb, line 77
def self.as_delete_stack_args(stack)
  { :stack_name => stack[:stack_id] }
end
as_update_change_set(meta, stack) click to toggle source
# File lib/cuffsert/cfarguments.rb, line 54
def self.as_update_change_set(meta, stack)
  cfargs = self.as_cloudformation_args(meta)
  cfargs[:change_set_name] = meta.stackname
  cfargs[:change_set_type] = 'UPDATE'
  if cfargs[:use_previous_template] = meta.stack_uri.nil?
    Array(stack[:parameters]).each do |param|
      key = param[:parameter_key]
      unless meta.parameters.include?(key)
        cfargs[:parameters] ||= []
        cfargs[:parameters] << {:parameter_key => key, :use_previous_value => true}
      end
    end
    if !meta.tags.empty?
      Array(stack[:tags]).each do |tag|
        unless meta.tags.include?(tag[:key])
          cfargs[:tags] << tag
        end
      end
    end
  end
  cfargs
end
ask_confirmation(input = STDIN, output = STDOUT) click to toggle source
# File lib/cuffsert/confirmation.rb, line 26
def self.ask_confirmation(input = STDIN, output = STDOUT)
  return false unless input.isatty
  state = Termios.tcgetattr(input)
  mystate = state.dup
  mystate.c_lflag |= Termios::ISIG
  mystate.c_lflag &= ~Termios::ECHO
  mystate.c_lflag &= ~Termios::ICANON
  output.write 'Continue? [yN] '
  begin
    Termios.tcsetattr(input, Termios::TCSANOW, mystate)
    answer = input.getc.chr.downcase
    output.write("\n")
    answer == 'y'
  rescue Interrupt
    false
  ensure
    Termios.tcsetattr(input, Termios::TCSANOW, state)
  end
end
build_meta(cli_args) click to toggle source
# File lib/cuffsert/metadata.rb, line 76
def self.build_meta(cli_args)
  default = self.meta_defaults(cli_args)
  config = self.metadata_if_present(cli_args)
  meta = CuffSert.meta_for_path(config, cli_args[:selector], default)
  CuffSert.cli_overrides(meta, cli_args)
end
confirmation(meta, action, change_set) click to toggle source
# File lib/cuffsert/confirmation.rb, line 46
def self.confirmation(meta, action, change_set)
  return false if meta.op_mode == :dry_run
  return true unless CuffSert.need_confirmation(meta, action, change_set)
  return CuffSert.ask_confirmation(STDIN, STDOUT)
end
determine_action(meta, cfclient, force_replace: false) { |action| ... } click to toggle source
# File lib/cuffsert/main.rb, line 14
def self.determine_action(meta, cfclient, force_replace: false)
  found = cfclient.find_stack_blocking(meta)

  if found && INPROGRESS_STATES.include?(found[:stack_status])
    message = Abort.new('Stack operation already in progress')
    action = MessageAction.new(message)
  else
    if found.nil?
      action = CreateStackAction.new(meta, nil)
    elsif found[:stack_status] == 'ROLLBACK_COMPLETE' || force_replace
      action = RecreateStackAction.new(meta, found)
    else
      action = UpdateStackAction.new(meta, found)
    end
    yield action
  end
  action
end
load_config(io) click to toggle source
# File lib/cuffsert/metadata.rb, line 50
def self.load_config(io)
  config = YAML.load(io)
  raise 'config does not seem to be a YAML hash?' unless config.is_a?(Hash)
  config = symbolize_keys(config)
  format = config.delete(:format)
  raise 'Please include Format: v1' if format.nil? || format.downcase != 'v1'
  config
end
load_template(stack_uri) click to toggle source
# File lib/cuffsert/cfarguments.rb, line 88
def self.load_template(stack_uri)
  file = stack_uri.to_s.sub(/^file:\/+/, '/')
  YAML.load(open(file).read)
end
make_renderer(cli_args) click to toggle source
# File lib/cuffsert/main.rb, line 33
def self.make_renderer(cli_args)
  if cli_args[:output] == :json
    JsonRenderer.new(STDOUT, STDERR, cli_args)
  else
    ProgressbarRenderer.new(STDOUT, STDERR, cli_args)
  end
end
meta_for_path(metadata, path, target = StackConfig.new) click to toggle source
# File lib/cuffsert/metadata.rb, line 59
def self.meta_for_path(metadata, path, target = StackConfig.new)
  target.update_from(metadata)
  candidate, *path = path
  key = candidate || metadata[:defaultpath]
  variants = metadata[:variants]
  if key.nil?
    raise "No DefaultPath found for #{variants.keys}" unless variants.nil?
    return target
  end
  target.append_path(key)

  raise "Missing variants section as expected by #{key}" if variants.nil?
  new_meta = variants[key.to_sym]
  raise "#{key.inspect} not found in variants" if new_meta.nil?
  self.meta_for_path(new_meta, path, target)
end
need_confirmation(meta, action, desc) click to toggle source
# File lib/cuffsert/confirmation.rb, line 4
def self.need_confirmation(meta, action, desc)
  return true if meta.op_mode == :always_ask
  return false if meta.op_mode == :dangerous_ok
  case action
  when :create
    false
  when :update
    change_set = desc
    change_set[:changes].any? do |change|
      rc = change[:resource_change]
      rc[:action] == 'Remove' || (
        rc[:action] == 'Modify' &&
        ['Always', 'True', 'Conditional'].include?(rc[:replacement])
      )
    end
  when :recreate
    true
  else
    true # safety first
  end
end
parse_cli_args(argv) click to toggle source
# File lib/cuffsert/cli_args.rb, line 8
def self.parse_cli_args(argv)
  args = {
    :output => :progressbar,
    :verbosity => 1,
    :force_replace => false,
    :op_mode => nil,
    :overrides => {
      :parameters => {},
      :tags => {},
    }
  }
  parser = OptionParser.new do |opts|
    opts.banner = "Upsert a CloudFormation template, reading creation options and metadata from a yaml file. Currently, parameter values, stack name and stack tags are read from metadata file. Version #{CuffSert::VERSION}."
    opts.separator('')
    opts.separator('Usage:')
    opts.separator('  cuffsert --name <stackname> stack.json')
    opts.separator('  cuffsert --name <stackname> {--parameter Name=Value | --tag Name=Value}... [stack.json]')
    opts.separator('  cuffsert --metadata <metadata.json> --selector <path/in/metadata> stack.json')
    opts.separator('  cuffsert --metadata <metadata.json> --selector <path/in/metadata> {--parameter Name=Value | --tag Name=Value}... [stack.json]')

    CuffBase.shared_cli_args(opts, args)

    opts.on('--metadata path', '-m path', 'Yaml file to read stack metadata from') do |path|
      path = '/dev/stdin' if path == '-'
      unless File.exist?(path)
        raise "--metadata #{path} does not exist"
      end
      args[:metadata] = path
    end

    opts.on('--selector selector', '-s selector', 'Dash or slash-separated variant names used to navigate the metadata') do |selector|
      args[:selector] = selector.split(/[-,\/]/)
    end

    opts.on('--name stackname', '-n name', 'Alternative stackname (default is to construct the name from the selector)') do |stackname|
      unless stackname =~ STACKNAME_RE
        raise "--name #{stackname} is expected to be #{STACKNAME_RE.inspect}"
      end
      args[:overrides][:stackname] = stackname
    end

    opts.on('--parameter k=v', '-p k=v', 'Set the value of a particular parameter, overriding any file metadata') do |kv|
      key, val = kv.split(/=/, 2)
      if val.nil?
        raise "--parameter #{kv} should be key=value"
      end
      if args[:overrides][:parameters].include?(key)
        raise "cli args include duplicate parameter #{key}"
      end
      args[:overrides][:parameters][key] = val
    end

    opts.on('--tag k=v', '-t k=v', 'Set a stack tag, overriding any file metadata') do |kv|
      key, val = kv.split(/=/, 2)
      if val.nil?
        raise "--tag #{kv} should be key=value"
      end
      if args[:overrides][:tags].include?(key)
        raise "cli args include duplicate tag #{key}"
      end
      args[:overrides][:tags][key] = val
    end

    opts.on('--s3-upload-prefix=prefix', 'Templates > 51200 bytes are uploaded here. Format: s3://bucket-name/[pre/fix]') do |prefix|
      unless prefix.start_with?('s3://')
        raise "Upload prefix #{prefix} must start with s3://"
      end
      args[:s3_upload_prefix] = prefix
    end

    opts.on('--json', 'Output events in JSON, no progressbar, colors') do
      args[:output] = :json
    end

    opts.on('--verbose', '-v', 'More detailed output. Once will print all stack events, twice will print debug info') do
      args[:verbosity] += 1
    end

    opts.on('--quiet', '-q', 'Output only fatal errors') do
      args[:verbosity] = 0
    end

    opts.on('--replace', 'Re-create the stack if it already exist') do
      args[:force_replace] = true
    end

    opts.on('--ask', 'Always ask for confirmation') do
      raise 'You can only use one of --yes, --ask and --dry-run' if args[:op_mode]
      args[:op_mode] = :always_ask
    end

    opts.on('--yes', '-y', 'Don\'t ask to replace and delete stack resources') do
      raise 'You can only use one of --yes, --ask and --dry-run' if args[:op_mode]
      args[:op_mode] = :dangerous_ok
    end

    opts.on('--dry-run', 'Describe what would be done') do
      raise 'You can only use one of --yes, --ask and --dry-run' if args[:op_mode]
      args[:op_mode] = :dry_run
    end

    opts.on('--help', '-h', 'Produce this message') do
      abort(opts.to_s)
    end
  end

  if argv.empty?
    abort(parser.to_s)
  else
    args[:stack_path] = parser.parse(argv)
    args
  end
end
run(argv) click to toggle source
# File lib/cuffsert/main.rb, line 41
def self.run(argv)
  cli_args = CuffSert.parse_cli_args(argv)
  CuffSert.validate_cli_args(cli_args)
  meta = CuffSert.build_meta(cli_args)
  cfclient = RxCFClient.new(meta.aws_region)
  action = CuffSert.determine_action(meta, cfclient, force_replace: cli_args[:force_replace]) do |a|
    a.confirmation = CuffSert.method(:confirmation)
    a.s3client = RxS3Client.new(cli_args, meta.aws_region) if cli_args[:s3_upload_prefix]
    a.cfclient = cfclient
  end
  action.validate!
  renderer = CuffSert.make_renderer(cli_args)
  RendererPresenter.new(action.as_observable, renderer)
end
s3_uri_to_https(uri, region) click to toggle source
# File lib/cuffsert/cfarguments.rb, line 81
def self.s3_uri_to_https(uri, region)
  bucket = uri.host
  key = uri.path
  host = region == 'us-east-1' ? 's3.amazonaws.com' : "s3-#{region}.amazonaws.com"
  "https://#{host}/#{bucket}#{key}"
end
state_category(state) click to toggle source
# File lib/cuffsert/cfstates.rb, line 30
def self.state_category(state)
  if BAD_STATES.include?(state)
    :bad
  elsif GOOD_STATES.include?(state)
    :good
  elsif INPROGRESS_STATES.include?(state)
    :progress
  else
    raise "Cannot categorize state #{state}"
  end
end
validate_and_urlify(stack_path) click to toggle source
# File lib/cuffsert/metadata.rb, line 34
def self.validate_and_urlify(stack_path)
  if stack_path =~ /^[A-Za-z0-9]+:/
    stack_uri = URI.parse(stack_path)
  else
    normalized = File.expand_path(stack_path)
    unless File.exist?(normalized)
      raise "Local file #{normalized} does not exist"
    end
    stack_uri = URI.join('file:///', normalized)
  end
  unless ['s3', 'file'].include?(stack_uri.scheme)
    raise "Uri #{stack_uri.scheme} is not supported"
  end
  stack_uri
end
validate_cli_args(cli_args) click to toggle source
# File lib/cuffsert/cli_args.rb, line 122
def self.validate_cli_args(cli_args)
  errors = []
  if cli_args[:stack_path] != nil && cli_args[:stack_path].size > 1
    errors << 'Require at most one template'
  end

  if cli_args[:metadata].nil? && cli_args[:overrides][:stackname].nil?
    errors << 'Without --metadata, you must supply --name to identify stack to update'
  end

  if cli_args[:selector] && cli_args[:metadata].nil?
    errors << 'You cannot use --selector without --metadata'
  end

  raise errors.join(', ') unless errors.empty?
end

Private Class Methods

cli_overrides(meta, cli_args) click to toggle source
# File lib/cuffsert/metadata.rb, line 107
def self.cli_overrides(meta, cli_args)
  meta.update_from(cli_args[:overrides])
  meta.aws_region = cli_args[:aws_region] || meta.aws_region
  meta.op_mode = cli_args[:op_mode] || meta.op_mode
  if (stack_path = (cli_args[:stack_path] || [])[0])
    meta.stack_uri = CuffSert.validate_and_urlify(stack_path)
  end
  meta
end
meta_defaults(cli_args) click to toggle source
# File lib/cuffsert/metadata.rb, line 85
def self.meta_defaults(cli_args)
  stack_path = (cli_args[:stack_path] || [])[0]
  if stack_path && File.exists?(stack_path)
    nil_params = CuffBase.empty_from_template(open(stack_path))
  else
    nil_params = {}
  end
  default = StackConfig.new
  default.update_from({:parameters => nil_params})
  default.suffix = File.basename(cli_args[:metadata], '.yml') if cli_args[:metadata]
  default
end
metadata_if_present(cli_args) click to toggle source
# File lib/cuffsert/metadata.rb, line 98
def self.metadata_if_present(cli_args)
  if cli_args[:metadata]
    io = open(cli_args[:metadata])
    CuffSert.load_config(io)
  else
    {}
  end
end
symbolize_keys(hash) click to toggle source
# File lib/cuffsert/metadata.rb, line 117
def self.symbolize_keys(hash)
  hash.each_with_object({}) do |(k, v), h|
    k = k.downcase.to_sym
    if k == :tags || k == :parameters
      h[k] = v.each_with_object({}) { |e, h| h[e['Name']] = e['Value'] }
    else
      h[k] = v.is_a?(Hash) ? symbolize_keys(v) : v
    end
  end
end
template_parameters(meta) click to toggle source
# File lib/cuffsert/cfarguments.rb, line 95
def self.template_parameters(meta)
  template_parameters = {}

  if meta.stack_uri.scheme == 's3'
    template_parameters[:template_url] = self.s3_uri_to_https(meta.stack_uri, meta.aws_region)
  elsif meta.stack_uri.scheme == 'https'
    if meta.stack_uri.host.end_with?('amazonaws.com')
      template_parameters[:template_url] = meta.stack_uri.to_s
    else
      raise 'Only HTTPS URLs pointing to amazonaws.com supported.'
    end
  elsif meta.stack_uri.scheme == 'file'
    template = CuffSert.load_template(meta.stack_uri).to_json
    if template.size <= 51200
      template_parameters[:template_body] = template
    end
  else
    raise "Unsupported scheme #{meta.stack_uri.scheme}"
  end

  template_parameters
end