lib/cfer/cfn/client.rb in cfer-0.2.0 vs lib/cfer/cfn/client.rb in cfer-0.3.0

- old
+ new

@@ -1,10 +1,11 @@ require_relative '../core/client' module Cfer::Cfn class Client < Cfer::Core::Client attr_reader :name + attr_reader :stack def initialize(options) @name = options[:stack_name] options.delete :stack_name @cfn = Aws::CloudFormation::Client.new(options) @@ -25,72 +26,95 @@ def method_missing(method, *args, &block) @cfn.send(method, *args, &block) end - def resolve(param) - # See if the value follows the form @<stack>.<output> - m = /^@(.+?)\.(.+)$/.match(param) - - if m - fetch_output(m[1], m[2]) - else - param - end - end - - def converge(stack, options = {}) Preconditions.check(@name).is_not_nil Preconditions.check(stack) { is_not_nil and has_type(Cfer::Core::Stack) } response = validate_template(template_body: stack.to_cfn) - parameters = response.parameters.map do |tmpl_param| - cfn_param = stack.parameters[tmpl_param.parameter_key] || raise(Cfer::Util::CferError, "Parameter #{tmpl_param.parameter_key} was required, but not specified") - cfn_param = resolve(cfn_param) + create_params = [] + update_params = [] - output_val = tmpl_param.no_echo ? '*****' : cfn_param - Cfer::LOGGER.debug "Parameter #{tmpl_param.parameter_key}=#{output_val}" + previous_parameters = + begin + fetch_parameters + rescue Cfer::Util::StackDoesNotExistError + nil + end - { - parameter_key: tmpl_param.parameter_key, - parameter_value: cfn_param, - use_previous_value: false - } + response.parameters.each do |tmpl_param| + input_param = stack.input_parameters[tmpl_param.parameter_key] + old_param = previous_parameters[tmpl_param.parameter_key] if previous_parameters + + Cfer::LOGGER.debug "== Evaluating Parameter '#{tmpl_param.parameter_key.to_s}':" + Cfer::LOGGER.debug "Input value: #{input_param.to_s || 'nil'}" + Cfer::LOGGER.debug "Previous value: #{old_param.to_s || 'nil'}" + + + if input_param + output_val = tmpl_param.no_echo ? '*****' : input_param + Cfer::LOGGER.debug "Parameter #{tmpl_param.parameter_key}=#{output_val}" + p = { + parameter_key: tmpl_param.parameter_key, + parameter_value: input_param, + use_previous_value: false + } + + create_params << p + update_params << p + else + if old_param + Cfer::LOGGER.debug "Parameter #{tmpl_param.parameter_key} is unspecified (unchanged)" + update_params << { + parameter_key: tmpl_param.parameter_key, + use_previous_value: true + } + else + Cfer::LOGGER.debug "Parameter #{tmpl_param.parameter_key} is unspecified (default)" + end + end end - options = { + Cfer::LOGGER.debug "===================" + + stack_options = { stack_name: name, template_body: stack.to_cfn, - parameters: parameters, capabilities: response.capabilities } - created = false - cfn_stack = begin - created = true - create_stack options + stack_options[:on_failure] = options[:on_failure] if options[:on_failure] + stack_options[:timeout_in_minutes] = options[:timeout] if options[:timeout] + + stack_options.merge! parse_stack_policy(:stack_policy, options[:stack_policy]) + stack_options.merge! parse_stack_policy(:stack_policy_during_update, options[:stack_policy_during_update]) + + cfn_stack = + begin + create_stack stack_options.merge parameters: create_params rescue Cfer::Util::StackExistsError - update_stack options + update_stack stack_options.merge parameters: update_params end flush_cache cfn_stack end # Yields to the given block for each CloudFormation event that qualifies, given the specified options. # @param options [Hash] The options hash # @option options [Fixnum] :number The maximum number of already-existing CloudFormation events to yield. # @option options [Boolean] :follow Set to true to wait until the stack enters a `COMPLETE` or `FAILED` state, yielding events as they occur. - def tail(options = {}, &block) + def tail(options = {}) q = [] event_id_highwater = nil counter = 0 number = options[:number] || 0 - for_each_event name do |event| - q.unshift event if counter < number + for_each_event name do |fetched_event| + q.unshift fetched_event if counter < number counter = counter + 1 end while q.size > 0 event = q.shift @@ -103,17 +127,17 @@ while running stack_status = describe_stacks(stack_name: name).stacks.first.stack_status running = running && (/.+_(COMPLETE|FAILED)$/.match(stack_status) == nil) yielding = true - for_each_event name do |event| - if event_id_highwater == event.event_id + for_each_event name do |fetched_event| + if event_id_highwater == fetched_event.event_id yielding = false end if yielding - q.unshift event + q.unshift fetched_event end end while q.size > 0 event = q.shift @@ -125,40 +149,90 @@ end end end def fetch_stack(stack_name = @name) - @stack_cache[stack_name] ||= describe_stacks(stack_name: stack_name).stacks.first.to_h + raise Cfer::Util::StackDoesNotExistError, 'Stack name must be specified' if stack_name == nil + begin + @stack_cache[stack_name] ||= describe_stacks(stack_name: stack_name).stacks.first.to_h + rescue Aws::CloudFormation::Errors::ValidationError => e + raise Cfer::Util::StackDoesNotExistError, e.message + end end + def fetch_parameters(stack_name = @name) + @stack_parameters[stack_name] ||= cfn_list_to_hash('parameter', fetch_stack(stack_name)[:parameters]) + end + + def fetch_outputs(stack_name = @name) + @stack_outputs[stack_name] ||= cfn_list_to_hash('output', fetch_stack(stack_name)[:outputs]) + end + + def fetch_output(stack_name, output_name) + fetch_outputs(stack_name)[output_name] || raise(Cfer::Util::CferError, "Stack #{stack_name} has no output named `#{output_name}`") + end + + def fetch_parameter(stack_name, param_name) + fetch_parameters(stack_name)[param_name] || raise(Cfer::Util::CferError, "Stack #{stack_name} has no parameter named `#{param_name}`") + end + def to_h @stack.to_h end private + def cfn_list_to_hash(attribute, list) + key = :"#{attribute}_key" + value = :"#{attribute}_value" + + Hash[ *list.map { |kv| [ kv[key].to_s, kv[value].to_s ] }.flatten ] + end + def flush_cache + Cfer::LOGGER.debug "*********** FLUSH CACHE ***************" + Cfer::LOGGER.debug "Stack cache: #{@stack_cache}" + Cfer::LOGGER.debug "Stack parameters: #{@stack_parameters}" + Cfer::LOGGER.debug "Stack outputs: #{@stack_outputs}" + Cfer::LOGGER.debug "***************************************" @stack_cache = {} + @stack_parameters = {} + @stack_outputs = {} end - def fetch_output(stack_name, output_name) - stack = fetch_stack(stack_name) - - output = stack[:outputs].find do |o| - o[:output_key] == output_name + def for_each_event(stack_name) + describe_stack_events(stack_name: stack_name).stack_events.each do |event| + yield event end + end - if output - output[:output_value] - else - raise CferError, "Stack #{stack_name} has no output value named `#{output_name}`" - end + # Validates a string as json + # + # @param string [String] + def is_json?(string) + JSON.parse(string) + true + rescue JSON::ParserError + false end - def for_each_event(stack_name) - describe_stack_events(stack_name: stack_name).stack_events.each do |event| - yield event + # Parses stack-policy-* options as an S3 URL, file to read, or JSON string + # + # @param name [String] Name of option: 'stack_policy' or 'stack_policy_during_update' + # @param value [String] String containing URL, filename or JSON string + # @return [Hash] Hash suitable for merging into options for create_stack or update_stack + def parse_stack_policy(name, value) + Cfer::LOGGER.debug "Using #{name} from: #{value}" + if value.nil? + {} + elsif value.match(/\A#{URI::regexp(%w[http https s3])}\z/) # looks like a URL + {"#{name}_url".to_sym => value} + elsif File.exist?(value) # looks like a file to read + {"#{name}_body".to_sym => File.read(value)} + elsif is_json?(value) # looks like a JSON string + {"#{name}_body".to_sym => value} + else # none of the above + raise Cfer::Util::CferError, "Stack policy must be an S3 url, a filename, or a valid json string" end end end end -