Module: CuffSert

Defined in:
lib/cuffsert/presenters.rb,
lib/cuffsert/main.rb,
lib/cuffsert/errors.rb,
lib/cuffsert/actions.rb,
lib/cuffsert/version.rb,
lib/cuffsert/cfstates.rb,
lib/cuffsert/cli_args.rb,
lib/cuffsert/messages.rb,
lib/cuffsert/metadata.rb,
lib/cuffsert/rxcfclient.rb,
lib/cuffsert/rxs3client.rb,
lib/cuffsert/cfarguments.rb,
lib/cuffsert/confirmation.rb

Overview

TODO:

  • propagate timeout here (from config?)

  • creation change-set: cfargs = ‘CREATE’

Defined Under Namespace

Classes: Abort, BaseAction, BasePresenter, BaseRenderer, ChangeSet, CreateStackAction, CuffSertError, Done, JsonRenderer, Message, MessageAction, NoChanges, ProgressbarRenderer, RawPresenter, RecreateStackAction, RendererPresenter, Report, RxCFClient, RxCFError, RxS3Client, StackConfig, Templates, UpdateStackAction

Constant Summary collapse

VERSION =
'0.14.2'
INPROGRESS_STATES =
%w[
  CREATE_IN_PROGRESS
  UPDATE_IN_PROGRESS
  UPDATE_COMPLETE_CLEANUP_IN_PROGRESS
  UPDATE_ROLLBACK_IN_PROGRESS
  UPDATE_ROLLBACK_COMPLETE_CLEANUP_IN_PROGRESS
  DELETE_IN_PROGRESS
]
GOOD_STATES =
%w[
  CREATE_COMPLETE
  ROLLBACK_COMPLETE
  UPDATE_COMPLETE
  DELETE_COMPLETE
  DELETE_SKIPPED
]
BAD_STATES =
%w[
  CREATE_FAILED
  UPDATE_ROLLBACK_COMPLETE
  UPDATE_ROLLBACK_FAILED
  UPDATE_FAILED
  DELETE_FAILED
  FAILED
]
FINAL_STATES =
GOOD_STATES + BAD_STATES
STACKNAME_RE =
/^[A-Za-z0-9_-]+$/
ACTION_ORDER =
['Add', 'Modify', 'Replace?', 'Replace!', 'Remove']
TIMEOUT =
10

Class Method Summary collapse

Class Method Details

.as_cloudformation_args(meta) ⇒ Object



12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
# 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) ⇒ Object



44
45
46
47
48
49
50
51
52
# 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) ⇒ Object



77
78
79
# 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) ⇒ Object



54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
# 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) ⇒ Object



26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
# 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) ⇒ Object



76
77
78
79
80
81
# File 'lib/cuffsert/metadata.rb', line 76

def self.build_meta(cli_args)
  default = self.meta_defaults(cli_args)
  config = self.(cli_args)
  meta = CuffSert.meta_for_path(config, cli_args[:selector], default)
  CuffSert.cli_overrides(meta, cli_args)
end

.cli_overrides(meta, cli_args) ⇒ Object



107
108
109
110
111
112
113
114
115
# 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

.confirmation(meta, action, change_set) ⇒ Object



46
47
48
49
50
# 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) ⇒ Object



14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# 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) ⇒ Object



50
51
52
53
54
55
56
57
# 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) ⇒ Object



88
89
90
91
# 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) ⇒ Object



33
34
35
36
37
38
39
# 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_defaults(cli_args) ⇒ Object



85
86
87
88
89
90
91
92
93
94
95
96
# 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

.meta_for_path(metadata, path, target = StackConfig.new) ⇒ Object



59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
# File 'lib/cuffsert/metadata.rb', line 59

def self.meta_for_path(, path, target = StackConfig.new)
  target.update_from()
  candidate, *path = path
  key = candidate || [:defaultpath]
  variants = [: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

.metadata_if_present(cli_args) ⇒ Object



98
99
100
101
102
103
104
105
# File 'lib/cuffsert/metadata.rb', line 98

def self.(cli_args)
  if cli_args[:metadata]
    io = open(cli_args[:metadata])
    CuffSert.load_config(io)
  else
    {}
  end
end

.need_confirmation(meta, action, desc) ⇒ Object



4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 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) ⇒ Object



8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
# 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) ⇒ Object



41
42
43
44
45
46
47
48
49
50
51
52
53
54
# 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) ⇒ Object



81
82
83
84
85
86
# 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) ⇒ Object



30
31
32
33
34
35
36
37
38
39
40
# 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

.symbolize_keys(hash) ⇒ Object



117
118
119
120
121
122
123
124
125
126
# 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) ⇒ Object



95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
# 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

.validate_and_urlify(stack_path) ⇒ Object



34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
# 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) ⇒ Object



122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
# 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