ryotarai/itamae

View on GitHub
lib/itamae/backend.rb

Summary

Maintainability
A
3 hrs
Test Coverage
require 'specinfra/core'
require 'singleton'
require 'io/console'
require 'net/ssh'

Specinfra::Configuration.error_on_missing_backend_type = true

module Specinfra
  module Configuration
    def self.sudo_password
      return ENV['SUDO_PASSWORD'] if ENV['SUDO_PASSWORD']
      return @sudo_password if @sudo_password

      # TODO: Fix this dirty hack
      return nil unless caller.any? {|call| call.include?('channel_data') }

      print "sudo password: "
      @sudo_password = STDIN.noecho(&:gets).strip
      print "\n"
      @sudo_password
    end
  end
end

module Itamae
  module Backend
    UnknownBackendTypeError = Class.new(StandardError)
    CommandExecutionError = Class.new(StandardError)
    SourceNotExistError = Class.new(StandardError)

    class << self
      def create(type, opts = {})
        self.const_get(type.capitalize).new(opts)
      end
    end

    class Base
      attr_reader :executed_commands
      
      def initialize(options)
        @options = options
        @backend = create_specinfra_backend
        @executed_commands = []
      end

      def run_command(commands, options = {})
        options = {error: true}.merge(options)

        command = build_command(commands, options)
        Itamae.logger.debug "Executing `#{command}`..."

        result = nil

        Itamae.logger.with_indent do
          reset_output_handler

          result = run_command_with_profiling(command)

          flush_output_handler_buffer

          if result.exit_status == 0 || !options[:error]
            method = :debug
            message = "exited with #{result.exit_status}"
          else
            method = :error
            message = "Command `#{command}` failed. (exit status: #{result.exit_status})"

            unless Itamae.logger.level == ::Logger::DEBUG
              result.stdout.each_line do |l|
                log_output_line("stdout", l, :error)
              end
              result.stderr.each_line do |l|
                log_output_line("stderr", l, :error)
              end
            end
          end

          Itamae.logger.public_send(method, message)
        end

        if options[:error] && result.exit_status != 0
          raise CommandExecutionError
        end

        result
      end

      def get_command(*args)
        @backend.command.get(*args)
      end

      def receive_file(src, dst = nil)
        if dst
          Itamae.logger.debug "Receiving a file from '#{src}' to '#{dst}'..."
        else
          Itamae.logger.debug "Receiving a file from '#{src}'..."
        end
        @backend.receive_file(src, dst)
      end

      def send_file(src, dst)
        Itamae.logger.debug "Sending a file from '#{src}' to '#{dst}'..."
        unless ::File.exist?(src)
          raise SourceNotExistError, "The file '#{src}' doesn't exist."
        end
        unless ::File.file?(src)
          raise SourceNotExistError, "'#{src}' is not a file."
        end
        @backend.send_file(src, dst)
      end

      def send_directory(src, dst)
        Itamae.logger.debug "Sending a directory from '#{src}' to '#{dst}'..."
        unless ::File.exist?(src)
          raise SourceNotExistError, "The directory '#{src}' doesn't exist."
        end
        unless ::File.directory?(src)
          raise SourceNotExistError, "'#{src}' is not a directory."
        end
        @backend.send_directory(src, dst)
      end

      def host_inventory
        @backend.host_inventory
      end

      def finalize
        # pass
      end

      private

      def create_specinfra_backend
        raise NotImplementedError
      end

      def reset_output_handler
        @buf = {}
        %w!stdout stderr!.each do |output_name|
          @buf[output_name] = ""
          handler = lambda do |str|
            lines = str.split(/\r?\n/, -1)
            @buf[output_name] += lines.pop
            unless lines.empty?
              lines[0] = @buf[output_name] + lines[0]
              @buf[output_name] = ""
              lines.each do |l|
                log_output_line(output_name, l)
              end
            end
          end
          @backend.public_send("#{output_name}_handler=", handler)
        end
      end

      def flush_output_handler_buffer
        @buf.each do |output_name, line|
          next if line.empty?
          log_output_line(output_name, line)
        end
      end

      def log_output_line(output_name, line, severity = :debug)
        line = line.gsub(/[[:cntrl:]]/, '')
        Itamae.logger.public_send(severity, "#{output_name} | #{line}")
      end

      def build_command(commands, options)
        if commands.is_a?(Array)
          command = commands.map do |cmd|
            cmd.shellescape
          end.join(' ')
        else
          command = commands
        end

        cwd = options[:cwd]
        if cwd
          command = "cd #{cwd.shellescape} && #{command}"
        end

        user = options[:user]
        if user
          command = "cd ~#{user.shellescape} ; #{command}"
          command = "sudo -H -u #{user.shellescape} -- #{shell.shellescape} -c #{command.shellescape}"
        end

        command
      end

      def shell
        @options[:shell]
      end

      def run_command_with_profiling(command)
        start_at = Time.now
        result = @backend.run_command(command)
        duration = Time.now.to_f - start_at.to_f

        @executed_commands << {command: command, duration: duration}

        result
      end
    end

    class Local < Base
      private
      def create_specinfra_backend
        Specinfra::Backend::Exec.new(
          shell: @options[:shell],
        )
      end
    end

    class Ssh < Base
      private
      def create_specinfra_backend
        Specinfra::Backend::Ssh.new(
          request_pty: true,
          host: ssh_options[:host_name],
          disable_sudo: disable_sudo?,
          ssh_options: ssh_options,
          shell: @options[:shell],
        )
      end

      def ssh_options
        opts = {}

        opts[:host_name] = @options[:host]

        # from ssh-config
        opts.merge!(Net::SSH::Config.for(@options[:host]))
        opts[:user] = @options[:user] || opts[:user] || Etc.getlogin
        opts[:keys] = [@options[:key]] if @options[:key]
        opts[:port] = @options[:port] if @options[:port]

        if @options[:vagrant]
          config = Tempfile.new('', Dir.tmpdir)
          hostname = opts[:host_name] || 'default'
          `vagrant ssh-config #{hostname} > #{config.path}`
          opts.merge!(Net::SSH::Config.for(hostname, [config.path]))
        end

        if @options[:ask_password]
          print "password: "
          password = STDIN.noecho(&:gets).strip
          print "\n"
          opts.merge!(password: password)
        end

        opts
      end

      def disable_sudo?
        !@options[:sudo]
      end
    end

    class Docker < Base
      def finalize
        image = @backend.commit_container
        Itamae.logger.info "Image created: #{image.id}"
      end

      private
      def create_specinfra_backend
        begin
          require 'docker'
        rescue LoadError
          Itamae.logger.fatal "To use docker backend, please install 'docker-api' gem"
        end

        # TODO: Move to Specinfra?
        Excon.defaults[:ssl_verify_peer] = @options[:tls_verify_peer]
        ::Docker.logger = Itamae.logger

        Specinfra::Backend::Docker.new(
          docker_image: @options[:image],
          docker_container: @options[:container],
          shell: @options[:shell],
        )
      end
    end
  end
end