module CuffSert
TODO:
-
propagate timeout here (from config?)
-
creation change-set: cfargs = 'CREATE'
TODO: Animate in-progress states
-
Present the error message in change_set properly - and abort
-
badness goes to stderr
-
change sets should present modification details indented under each entry
-
property direct modification
-
properties through parameter change
-
indirect change through other resource (“causing_entity”: “Lb.DNSName”)
-
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