class Kumogata::PostProcessing TRIGGER_TIMING = [:create, :update] def initialize(options) @options = options @commands = {} @command_options = { :undent => true, :trim_mode => nil, } end def fetch!(template) _post = template.delete(:_post) return unless _post options = _post[:options] || {} @command_options.merge(options) outputs = template['Outputs'] || {} _post.fetch(:commands).each do |name, attrs| unless attrs.kind_of?(Hash) and attrs['command'] raise "Invalid post processing: #{name} => #{attrs.inspect}" end timing = [(attrs['after'] || TRIGGER_TIMING)].flatten.map {|i| i.to_sym } command = attrs['command'] validate_timing(name, timing) validate_command_template(name, command, outputs) @commands[name] = { :after => timing, :command => command, } if (ssh = attrs['ssh']) validate_ssh(name, ssh, outputs) @commands[name][:ssh] = ssh end end end def run(timing, outputs) results = [] @commands.each do |name, attrs| next unless attrs[:after].include?(timing) print_command(name) out, err, status = run_command(attrs, outputs) print_command_result(out, err, status) results << { name => { 'ExitStatus' => status.to_i, 'StdOut' => out.force_encoding('UTF-8'), 'StdErr' => err.force_encoding('UTF-8'), } } end save_command_results(results) unless results.empty? end private def validate_timing(name, timing) timing.each do |t| unless TRIGGER_TIMING.include?(t) raise "Unknown post processing timing: #{name} => #{timing.inspect}" end end end def validate_ssh(name, ssh, outputs) host, user, options = ssh.values_at('host', 'user', 'options') unless host and user raise "`host` and `user` is required for post processing ssh: #{name}" end if host.kind_of?(Hash) if host.keys != ['Key'] raise "Invalid post processing ssh host: #{name} => #{host.inspect}" end host_key, host_value = host.first ssh['host'] = "<%= #{host_key} #{host_value.to_s.inspect} %>" else ssh['host'] = host.to_s end validate_command_template(name, ssh['host'], outputs) if user.kind_of?(Hash) if user.keys != ['Key'] raise "Invalid post processing ssh user: #{name} => #{user.inspect}" end user_key, user_value = user.first ssh['user'] = "<%= #{user_key} #{user_value.to_s.inspect} %>" else ssh['user'] = user.to_s end validate_command_template(name, ssh['user'], outputs) if options and not options.kind_of?(Hash) raise "Invalid post processing ssh options: #{name} => #{options.inspect}" end end def run_command(attrs, outputs) command, ssh = attrs.values_at(:command, :ssh) if ssh run_ssh_command(ssh, command, outputs) else run_shell_command(command, outputs) end end def run_ssh_command(ssh, command, outputs) host, user, options = ssh.values_at('host', 'user', 'options') host = evaluate_command_template(host, outputs) user = evaluate_command_template(user, outputs) args = [host, user] args << ssh['options'] if ssh['options'] command = evaluate_command_template(command, outputs) connect_tries = (ssh['connect_tries'] || 36).to_i retry_interval = (ssh['retry_interval'] || 5).to_i stderr_orig = nil begin stderr_orig = STDERR.dup STDERR.reopen('/dev/null', 'w') begin retryable(:tries => connect_tries, :on => Net::SSH::Disconnect, :sleep => retry_interval) do Net::SSH.start(*args) {|ssh| ssh_exec!(ssh, command) } end rescue Net::SSH::HostKeyMismatch => e e.remember_host! retry end ensure STDERR.reopen(stderr_orig) end end def ssh_exec!(ssh, command) stdout_data = '' stderr_data = '' exit_code = nil #exit_signal = nil stdout_stream = create_stdout_stream stderr_stream = create_stderr_stream ssh.open_channel do |channel| channel.exec(command) do |ch, success| unless success raise "Couldn't execute command #{command.inspect} (ssh.channel.exec)" end channel.on_data do |ch, data| stdout_stream.push data stdout_data << data end channel.on_extended_data do |ch, type, data| stderr_stream.push data stderr_data << data end channel.on_request('exit-status') do |ch, data| exit_code = data.read_long end #channel.on_request('exit-signal') do |ch, data| # exit_signal = data.read_long #end end end ssh.loop stdout_stream.close stderr_stream.close #[stdout_data, stderr_data, exit_code, exit_signal] [stdout_data, stderr_data, exit_code] end def run_shell_command(command, outputs) command = evaluate_command_template(command, outputs) stdout_data = '' stderr_data = '' exit_code = nil Open3.popen3(command) do |stdin, stdout, stderr, wait_thr| mutex = Mutex.new th_out = Thread.start do stdout_stream = create_stdout_stream stdout.each_line do |line| mutex.synchronize do stdout_stream.push line end stdout_data << line end stdout_stream.close end th_err = Thread.start do stderr_stream = create_stderr_stream stderr.each_line do |line| mutex.synchronize do stderr_stream.push line end stderr_data << line end stderr_stream.close end th_out.join th_err.join exit_code = wait_thr.value end #[stdout_data, stderr_data, exit_code, exit_signal] [stdout_data, stderr_data, exit_code] end def validate_command_template(name, command, outputs) command = command.undent if @command_options[:undent] trim_mode = @command_options[:trim_mode] expected_outputs = Set.new scope = Object.new scope.instance_variable_set(:@__expected_outputs__, expected_outputs) scope.instance_eval(<<-EOS) def Key(name) @__expected_outputs__ << name end ERB.new(#{command.inspect}, nil, #{trim_mode.inspect}).result(binding) EOS expected_outputs.each do |key| unless outputs.keys.include?(key) raise "Unknown output: #{name} => #{key.inspect}" end end end def evaluate_command_template(command, outputs) command = command.undent if @command_options[:undent] trim_mode = @command_options[:trim_mode] scope = Object.new scope.instance_variable_set(:@__outputs__, outputs) scope.instance_eval(<<-EOS) def Key(name) @__outputs__[name] end ERB.new(#{command.inspect}, nil, #{trim_mode.inspect}).result(binding) EOS end def print_command(name) puts <<-EOS Command: #{name.intense_blue} EOS end def create_stdout_stream Kumogata::StringStream.new do |line| puts '1> '.intense_green + line $stdout.flush end end def create_stderr_stream Kumogata::StringStream.new do |line| puts '2> '.intense_red + line $stdout.flush end end def print_command_result(out, err, status) # XXX: status = status.to_i puts <<-EOS Status: #{status.zero? ? status : status.to_s.red} EOS end def save_command_results(results) puts <<-EOS (Save to `#{@options.command_result_log}`) EOS open(@options.command_result_log, 'wb') do |f| f.puts JSON.pretty_generate(results) end end def validate_stack_name(stack_name) return unless stack_name unless /\A[a-zA-Z][-a-zA-Z0-9]*\Z/i =~ stack_name raise "1 validation error detected: Value '#{stack_name}' at 'stackName' failed to satisfy constraint: Member must satisfy regular expression pattern: [a-zA-Z][-a-zA-Z0-9]*" end end end