From 2a8a04ebb9b15b90f1aeecc5df9a30e3e1bec6fd Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 10 Mar 2014 22:38:31 -0700 Subject: [PATCH] communicators/winrm: initial import from vagrant-windows --- plugins/communicators/winrm/communicator.rb | 131 +++++++++++++++ plugins/communicators/winrm/config.rb | 46 ++++++ plugins/communicators/winrm/errors.rb | 26 +++ plugins/communicators/winrm/plugin.rb | 36 +++++ plugins/communicators/winrm/shell.rb | 169 ++++++++++++++++++++ templates/locales/comm_winrm.yml | 22 +++ vagrant.gemspec | 1 + 7 files changed, 431 insertions(+) create mode 100644 plugins/communicators/winrm/communicator.rb create mode 100644 plugins/communicators/winrm/config.rb create mode 100644 plugins/communicators/winrm/errors.rb create mode 100644 plugins/communicators/winrm/plugin.rb create mode 100644 plugins/communicators/winrm/shell.rb create mode 100644 templates/locales/comm_winrm.yml diff --git a/plugins/communicators/winrm/communicator.rb b/plugins/communicators/winrm/communicator.rb new file mode 100644 index 000000000..3d2ebd55a --- /dev/null +++ b/plugins/communicators/winrm/communicator.rb @@ -0,0 +1,131 @@ +require "timeout" + +require "log4r" + +require_relative "shell" + +module VagrantPlugins + module CommunicatorWinRM + # Provides communication channel for Vagrant commands via WinRM. + class Communicator < Vagrant.plugin("2", :communicator) + def self.match?(machine) + # This is useless, and will likely be removed in the future (this + # whole method). + true + end + + def initialize(machine) + @machine = machine + @logger = Log4r::Logger.new("vagrant::communication::winrm") + @shell = nil + + @logger.debug("Initializing WinRMCommunicator") + end + + def ready? + @logger.debug("Checking whether WinRM is ready...") + + Timeout.timeout(@machine.config.winrm.timeout) do + shell.powershell("hostname") + end + + @logger.info("WinRM is ready!") + return true + rescue Vagrant::Errors::VagrantError => e + # We catch a `VagrantError` which would signal that something went + # wrong expectedly in the `connect`, which means we didn't connect. + @logger.info("WinRM not up: #{e.inspect}") + + # We reset the shell to trigger calling of winrm_finder again. + # This resolves a problem when using vSphere where the ssh_info was not refreshing + # thus never getting the correct hostname. + @shell = nil + return false + end + + def shell + @shell ||= create_shell + end + + def execute(command, opts={}, &block) + opts = { + :error_check => true, + :error_class => Errors::ExecutionError, + :error_key => :execution_error, + :command => command, + :shell => :powershell + }.merge(opts || {}) + exit_status = do_execute(command, opts[:shell], &block) + if opts[:error_check] && exit_status != 0 + raise_execution_error(opts, exit_status) + end + exit_status + end + alias_method :sudo, :execute + + def test(command, opts=nil) + @logger.debug("Testing: #{command}") + + # HACK: to speed up Vagrant 1.2 OS detection, skip checking for *nix OS + return false unless (command =~ /^uname|^cat \/etc|^cat \/proc|grep 'Fedora/).nil? + + opts = { :error_check => false }.merge(opts || {}) + execute(command, opts) == 0 + end + + def upload(from, to) + @logger.debug("Uploading: #{from} to #{to}") + shell.upload(from, to) + end + + def download(from, to) + @logger.debug("Downloading: #{from} to #{to}") + shell.download(from, to) + end + + protected + + # This creates anew WinRMShell based on the information we know + # about this machine. + def create_shell + host_address = @machine.config.winrm.host + if !host_address + ssh_info = @machine.ssh_info + raise Errors::WinRMNotReady if !ssh_info + host_address = ssh_info[:host] + end + + host_port = @machine.config.winrm.port + if @machine.config.winrm.guest_port + # TODO: search by guest port + end + + WinRMShell.new( + host_address, + @machine.config.winrm.username, + @machine.config.winrm.password, + port: host_port, + timeout_in_seconds: @machine.config.winrm.timeout, + max_tries: @machine.config.winrm.max_tries, + ) + end + + def do_execute(command, shell, &block) + if shell.eql? :cmd + shell.cmd(command, &block)[:exitcode] + else + command = VagrantWindows.load_script("command_alias.ps1") << "\r\n" << command << "\r\nexit $LASTEXITCODE" + shell.powershell(command, &block)[:exitcode] + end + end + + def raise_execution_error(opts, exit_code) + # The error classes expect the translation key to be _key, but that makes for an ugly + # configuration parameter, so we set it here from `error_key` + msg = "Command execution failed with an exit code of #{exit_code}" + error_opts = opts.merge(:_key => opts[:error_key], :message => msg) + raise opts[:error_class], error_opts + end + end #WinRM class + end +end diff --git a/plugins/communicators/winrm/config.rb b/plugins/communicators/winrm/config.rb new file mode 100644 index 000000000..5159d227d --- /dev/null +++ b/plugins/communicators/winrm/config.rb @@ -0,0 +1,46 @@ +module VagrantPlugins + module CommunicatorWinRM + class Config < Vagrant.plugin("2", :config) + attr_accessor :username + attr_accessor :password + attr_accessor :host + attr_accessor :port + attr_accessor :guest_port + attr_accessor :max_tries + attr_accessor :timeout + + def initialize + @username = UNSET_VALUE + @password = UNSET_VALUE + @host = UNSET_VALUE + @port = UNSET_VALUE + @guest_port = UNSET_VALUE + @max_tries = UNSET_VALUE + @timeout = UNSET_VALUE + end + + def finalize! + @username = "vagrant" if @username == UNSET_VALUE + @password = "vagrant" if @password == UNSET_VALUE + @host = nil if @host == UNSET_VALUE + @port = 5985 if @port == UNSET_VALUE + @guest_port = 5985 if @guest_port == UNSET_VALUE + @max_tries = 20 if @max_tries == UNSET_VALUE + @timeout = 1800 if @timeout == UNSET_VALUE + end + + def validate(machine) + errors = [] + + errors << "winrm.username cannot be nil." if machine.config.winrm.username.nil? + errors << "winrm.password cannot be nil." if machine.config.winrm.password.nil? + errors << "winrm.port cannot be nil." if machine.config.winrm.port.nil? + errors << "winrm.guest_port cannot be nil." if machine.config.winrm.guest_port.nil? + errors << "winrm.max_tries cannot be nil." if machine.config.winrm.max_tries.nil? + errors << "winrm.timeout cannot be nil." if machine.config.winrm.timeout.nil? + + { "WinRM" => errors } + end + end + end +end diff --git a/plugins/communicators/winrm/errors.rb b/plugins/communicators/winrm/errors.rb new file mode 100644 index 000000000..ce3ab2066 --- /dev/null +++ b/plugins/communicators/winrm/errors.rb @@ -0,0 +1,26 @@ +module VagrantPlugins + module CommunicatorWinRM + module Errors + # A convenient superclass for all our errors. + class WinRMError < Vagrant::Errors::VagrantError + error_namespace("vagrant_winrm.errors") + end + + class AuthError < WinRMError + error_key(:auth_error) + end + + class ExecutionError < WinRMError + error_key(:execution_error) + end + + class InvalidShell < WinRMError + error_key(:invalid_shell) + end + + class WinRMNotReady < WinRMError + error_key(:winrm_not_ready) + end + end + end +end diff --git a/plugins/communicators/winrm/plugin.rb b/plugins/communicators/winrm/plugin.rb new file mode 100644 index 000000000..28ba864b8 --- /dev/null +++ b/plugins/communicators/winrm/plugin.rb @@ -0,0 +1,36 @@ +require "vagrant" + +module VagrantPlugins + module CommunicatorWinRM + autoload :Errors, File.expand_path("../errors", __FILE__) + + class Plugin < Vagrant.plugin("2") + name "winrm communicator" + description <<-DESC + This plugin allows Vagrant to communicate with remote machines using + WinRM. + DESC + + communicator("winrm") do + require File.expand_path("../communicator", __FILE__) + init! + Communicator + end + + config("winrm") do + require_relative "config" + Config + end + + protected + + def self.init! + return if defined?(@_init) + I18n.load_path << File.expand_path( + "templates/locales/comm_winrm.yml", Vagrant.source_root) + I18n.reload! + @_init = true + end + end + end +end diff --git a/plugins/communicators/winrm/shell.rb b/plugins/communicators/winrm/shell.rb new file mode 100644 index 000000000..f6e02487e --- /dev/null +++ b/plugins/communicators/winrm/shell.rb @@ -0,0 +1,169 @@ +require "timeout" + +require "log4r" +require "winrm" + +require "vagrant/util/retryable" + +module VagrantPlugins + module CommunicatorWinRM + class WinRMShell + include Vagrant::Util::Retryable + + # These are the exceptions that we retry because they represent + # errors that are generally fixed from a retry and don't + # necessarily represent immediate failure cases. + @@exceptions_to_retry_on = [ + HTTPClient::KeepAliveDisconnected, + WinRM::WinRMHTTPTransportError, + Errno::EACCES, + Errno::EADDRINUSE, + Errno::ECONNREFUSED, + Errno::ECONNRESET, + Errno::ENETUNREACH, + Errno::EHOSTUNREACH, + Timeout::Error + ] + + attr_reader :logger + attr_reader :username + attr_reader :password + attr_reader :host + attr_reader :port + attr_reader :timeout_in_seconds + attr_reader :max_tries + + def initialize(host, username, password, options = {}) + @logger = Log4r::Logger.new("vagrant::communication::winrmshell") + @logger.debug("initializing WinRMShell") + + @host = host + @port = options[:port] || 5985 + @username = username + @password = password + @timeout_in_seconds = options[:timeout_in_seconds] || 60 + @max_tries = options[:max_tries] || 20 + end + + def powershell(command, &block) + execute_shell(command, :powershell, &block) + end + + def cmd(command, &block) + execute_shell(command, :cmd, &block) + end + + def wql(query) + execute_wql(query) + end + + def upload(from, to) + @logger.debug("Uploading: #{from} to #{to}") + file_name = (cmd("echo %TEMP%\\winrm-upload-#{rand()}"))[:data][0][:stdout].chomp + powershell <<-EOH + if(Test-Path #{to}) { + rm #{to} + } + EOH + Base64.encode64(IO.binread(from)).gsub("\n",'').chars.to_a.each_slice(8000-file_name.size) do |chunk| + out = cmd("echo #{chunk.join} >> \"#{file_name}\"") + end + powershell <<-EOH + mkdir $([System.IO.Path]::GetDirectoryName(\"#{to}\")) + $base64_string = Get-Content \"#{file_name}\" + $bytes = [System.Convert]::FromBase64String($base64_string) + $new_file = [System.IO.Path]::GetFullPath(\"#{to}\") + [System.IO.File]::WriteAllBytes($new_file,$bytes) + EOH + end + + def download(from, to) + @logger.debug("Downloading: #{from} to #{to}") + output = powershell("[System.convert]::ToBase64String([System.IO.File]::ReadAllBytes(\"#{from}\"))") + contents = output[:data].map!{|line| line[:stdout]}.join.gsub("\\n\\r", '') + out = Base64.decode64(contents) + IO.binwrite(to, out) + end + + protected + + def execute_shell(command, shell=:powershell, &block) + raise Errors::InvalidShell, shell: shell unless shell == :cmd || shell == :powershell + + begin + execute_shell_with_retry(command, shell, &block) + rescue => e + raise_winrm_exception(e, shell, command) + end + end + + def execute_shell_with_retry(command, shell, &block) + retryable(:tries => @max_tries, :on => @@exceptions_to_retry_on, :sleep => 10) do + @logger.debug("#{shell} executing:\n#{command}") + output = session.send(shell, command) do |out, err| + block.call(:stdout, out) if block_given? && out + block.call(:stderr, err) if block_given? && err + end + @logger.debug("Exit status: #{output[:exitcode].inspect}") + return output + end + end + + def execute_wql(query) + retryable(:tries => @max_tries, :on => @@exceptions_to_retry_on, :sleep => 10) do + @logger.debug("#executing wql: #{query}") + output = session.wql(query) + @logger.debug("wql result: #{output.inspect}") + return output + end + rescue => e + raise_winrm_exception(e, :wql, query) + end + + def raise_winrm_exception(winrm_exception, shell, command) + # If the error is a 401, we can return a more specific error message + if winrm_exception.message.include?("401") + raise Errors::AuthError, + :user => @username, + :password => @password, + :endpoint => endpoint, + :message => winrm_exception.message + end + + raise Errors::ExecutionError, + :shell => shell, + :command => command, + :message => winrm_exception.message + end + + def new_session + @logger.info("Attempting to connect to WinRM...") + @logger.info(" - Host: #{@host}") + @logger.info(" - Port: #{@port}") + @logger.info(" - Username: #{@username}") + + client = ::WinRM::WinRMWebService.new(endpoint, :plaintext, endpoint_options) + client.set_timeout(@timeout_in_seconds) + client.toggle_nori_type_casting(:off) #we don't want coersion of types + client + end + + def session + @session ||= new_session + end + + def endpoint + "http://#{@host}:#{@port}/wsman" + end + + def endpoint_options + { :user => @username, + :pass => @password, + :host => @host, + :port => @port, + :operation_timeout => @timeout_in_seconds, + :basic_auth_only => true } + end + end #WinShell class + end +end diff --git a/templates/locales/comm_winrm.yml b/templates/locales/comm_winrm.yml new file mode 100644 index 000000000..f88e31b2e --- /dev/null +++ b/templates/locales/comm_winrm.yml @@ -0,0 +1,22 @@ +en: + vagrant_winrm: + errors: + auth_error: |- + An authorization error occurred while connecting to WinRM. + + User: %{user} + Password: %{password} + Endpoint: %{endpoint} + Message: %{message} + execution_error: |- + An error occurred executing a remote WinRM command. + + Shell: %{shell} + Command: %{command} + Message: %{message} + invalid_shell: |- + %{shell} is not a supported type of Windows shell. + winrm_not_ready: |- + The box is not able to report an address for WinRM to connect to yet. + WinRM cannot access this Vagrant environment. Please wait for the + Vagrant environment to be running and try again. diff --git a/vagrant.gemspec b/vagrant.gemspec index 3f40e15d7..4145d3ea3 100644 --- a/vagrant.gemspec +++ b/vagrant.gemspec @@ -24,6 +24,7 @@ Gem::Specification.new do |s| s.add_dependency "net-scp", "~> 1.1.0" s.add_dependency "rb-kqueue", "~> 0.2.0" s.add_dependency "wdm", "~> 0.1.0" + s.add_dependency "winrm", "~> 1.1.3" s.add_development_dependency "rake" s.add_development_dependency "contest", ">= 0.1.2"