Add WinSSH communicator

This commit is contained in:
Chris Roberts 2017-04-15 07:12:58 -07:00
parent ca1e7b9a6b
commit c56acfab94
6 changed files with 795 additions and 7 deletions

View File

@ -143,7 +143,7 @@ module VagrantPlugins
# If we're already attempting to switch out the SSH key, then
# just return that we're ready (for Machine#guest).
@lock.synchronize do
return true if @inserted_key || !@machine.config.ssh.insert_key
return true if @inserted_key || !machine_config_ssh.insert_key
@inserted_key = true
end
@ -458,9 +458,9 @@ module VagrantPlugins
# Determine the shell to execute. Prefer the explicitly passed in shell
# over the default configured shell. If we are using `sudo` then we
# need to wrap the shell in a `sudo` call.
cmd = @machine.config.ssh.shell
cmd = machine_config_ssh.shell
cmd = shell if shell
cmd = @machine.config.ssh.sudo_command.gsub("%c", cmd) if sudo
cmd = machine_config_ssh.sudo_command.gsub("%c", cmd) if sudo
cmd
end
@ -482,7 +482,7 @@ module VagrantPlugins
# Open the channel so we can execute or command
channel = connection.open_channel do |ch|
if @machine.config.ssh.pty
if machine_config_ssh.pty
ch.request_pty do |ch2, success|
pty = success && command != ""
@ -611,7 +611,7 @@ module VagrantPlugins
begin
keep_alive = nil
if @machine.config.ssh.keep_alive
if machine_config_ssh.keep_alive
# Begin sending keep-alive packets while we wait for the script
# to complete. This avoids connections closing on long-running
# scripts.
@ -687,9 +687,13 @@ module VagrantPlugins
end
def generate_environment_export(env_key, env_value)
template = @machine.config.ssh.export_command_template
template = machine_config_ssh.export_command_template
template.sub("%ENV_KEY%", env_key).sub("%ENV_VALUE%", env_value) + "\n"
end
def machine_config_ssh
@machine.config.ssh
end
end
end
end

View File

@ -0,0 +1,157 @@
require File.expand_path("../../ssh/communicator", __FILE__)
module VagrantPlugins
module CommunicatorWinSSH
# This class provides communication with a Windows VM running
# the Windows native port of OpenSSH
class Communicator < VagrantPlugins::CommunicatorSSH::Communicator
def initialize(machine)
super
@logger = Log4r::Logger.new("vagrant::communication::winssh")
end
# Executes the command on an SSH connection within a login shell.
def shell_execute(connection, command, **opts)
opts = {
sudo: false,
shell: nil
}.merge(opts)
sudo = opts[:sudo]
@logger.info("Execute: #{command}")
exit_status = nil
# Open the channel so we can execute or command
channel = connection.open_channel do |ch|
marker_found = false
data_buffer = ''
stderr_marker_found = false
stderr_data_buffer = ''
tfile = Tempfile.new('vagrant-ssh')
remote_ext = machine_config_ssh.shell.to_s == "powershell" ? "ps1" : "bat"
remote_name = "C:\\Windows\\Temp\\#{File.basename(tfile.path)}.#{remote_ext}"
if machine_config_ssh.shell.to_s == "powershell"
base_cmd = "powershell -File #{remote_name}"
tfile.puts <<-SCRIPT.force_encoding('ASCII-8BIT')
Remove-Item #{remote_name}
Write-Host #{CMD_GARBAGE_MARKER}
[Console]::Error.WriteLine("#{CMD_GARBAGE_MARKER}")
#{command}
SCRIPT
else
base_cmd = remote_name
tfile.puts <<-SCRIPT.force_encoding('ASCII-8BIT')
ECHO OFF
ECHO #{CMD_GARBAGE_MARKER}
ECHO #{CMD_GARBAGE_MARKER} 1>&2
#{command}
SCRIPT
end
tfile.close
upload(tfile.path, remote_name)
tfile.delete
ch.exec(base_cmd) do |ch2, _|
# Setup the channel callbacks so we can get data and exit status
ch2.on_data do |ch3, data|
# Filter out the clear screen command
data = remove_ansi_escape_codes(data)
if !marker_found
data_buffer << data
marker_index = data_buffer.index(CMD_GARBAGE_MARKER)
if marker_index
marker_found = true
data_buffer.slice!(0, marker_index + CMD_GARBAGE_MARKER.size)
data.replace(data_buffer)
data_buffer = nil
end
end
if block_given? && marker_found
yield :stdout, data
end
end
ch2.on_extended_data do |ch3, type, data|
# Filter out the clear screen command
data = remove_ansi_escape_codes(data)
@logger.debug("stderr: #{data}")
if !stderr_marker_found
stderr_data_buffer << data
marker_index = stderr_data_buffer.index(CMD_GARBAGE_MARKER)
if marker_index
marker_found = true
stderr_data_buffer.slice!(0, marker_index + CMD_GARBAGE_MARKER.size)
data.replace(stderr_data_buffer.lstrip)
data_buffer = nil
end
end
if block_given? && marker_found
yield :stderr, data
end
end
ch2.on_request("exit-status") do |ch3, data|
exit_status = data.read_long
@logger.debug("Exit status: #{exit_status}")
# Close the channel, since after the exit status we're
# probably done. This fixes up issues with hanging.
ch.close
end
end
end
begin
keep_alive = nil
if @machine.config.ssh.keep_alive
# Begin sending keep-alive packets while we wait for the script
# to complete. This avoids connections closing on long-running
# scripts.
keep_alive = Thread.new do
loop do
sleep 5
@logger.debug("Sending SSH keep-alive...")
connection.send_global_request("keep-alive@openssh.com")
end
end
end
# Wait for the channel to complete
begin
channel.wait
rescue Errno::ECONNRESET, IOError
@logger.info(
"SSH connection unexpected closed. Assuming reboot or something.")
exit_status = 0
pty = false
rescue Net::SSH::ChannelOpenFailed
raise Vagrant::Errors::SSHChannelOpenFail
rescue Net::SSH::Disconnect
raise Vagrant::Errors::SSHDisconnected
end
ensure
# Kill the keep-alive thread
keep_alive.kill if keep_alive
end
# Return the final exit status
return exit_status
end
def machine_config_ssh
@machine.config.winssh
end
end
end
end

View File

@ -0,0 +1,25 @@
require File.expand_path("../../../kernel_v2/config/ssh", __FILE__)
module VagrantPlugins
module CommunicatorWinSSH
class Config < VagrantPlugins::Kernel_V2::SSHConfig
def finalize!
@shell = "cmd" if @shell == UNSET_VALUE
@sudo_command = "cmd" if @sudo_command == UNSET_VALUE
if @export_command_template == UNSET_VALUE
if @shell == "cmd"
@export_command_template = 'set %ENV_KEY%="%ENV_VALUE%"'
else
@export_command_template = '$env:%ENV_KEY%="%ENV_VALUE%"'
end
end
super
end
def to_s
"WINSSH"
end
end
end
end

View File

@ -0,0 +1,21 @@
require "vagrant"
module VagrantPlugins
module CommunicatorWinSSH
class Plugin < Vagrant.plugin("2")
name "windows ssh communicator"
description <<-DESC
DESC
communicator("winssh") do
require File.expand_path("../communicator", __FILE__)
Communicator
end
config("winssh") do
require_relative "config"
Config
end
end
end
end

View File

@ -18,8 +18,11 @@ module VagrantPlugins
args = " #{args.join(" ")}"
end
if @machine.config.vm.communicator == :winrm
case @machine.config.vm.communicator
when :winrm
provision_winrm(args)
when :winssh
provision_winssh(args)
else
provision_ssh(args)
end
@ -94,6 +97,59 @@ module VagrantPlugins
end
end
# This is the provision method called if Windows OpenSSH is what is running
# on the remote end, which assumes a non-POSIX-style host.
def provision_winssh(args)
with_script_file do |path|
# Upload the script to the machine
@machine.communicate.tap do |comm|
env = config.env.map{|k,v| comm.generate_environment_export(k, v)}.join
remote_ext = @machine.config.winssh.shell == "powershell" ? "ps1" : "bat"
upload_path = "C:\\Windows\\Temp\\#{File.basename(path)}.#{remote_ext}"
if remote_ext == "ps1"
# Copy powershell_args from configuration
shell_args = config.powershell_args
# For PowerShell scripts bypass the execution policy unless already specified
shell_args += " -ExecutionPolicy Bypass" if config.powershell_args !~ /[-\/]ExecutionPolicy/i
# CLIXML output is kinda useless, especially on non-windows hosts
shell_args += " -OutputFormat Text" if config.powershell_args !~ /[-\/]OutputFormat/i
command = "#{env}\npowershell #{shell_args} #{upload_path}#{args}"
else
command = "#{env}\n#{upload_path}#{args}"
end
# Reset upload path permissions for the current ssh user
info = nil
retryable(on: Vagrant::Errors::SSHNotReady, tries: 3, sleep: 2) do
info = @machine.ssh_info
raise Vagrant::Errors::SSHNotReady if info.nil?
end
comm.upload(path.to_s, upload_path)
if config.name
@machine.ui.detail(I18n.t("vagrant.provisioners.shell.running",
script: "script: #{config.name}"))
elsif config.path
@machine.ui.detail(I18n.t("vagrant.provisioners.shell.running",
script: path.to_s))
else
@machine.ui.detail(I18n.t("vagrant.provisioners.shell.running",
script: "inline script"))
end
# Execute it with sudo
comm.execute(
command,
sudo: config.privileged,
error_key: :ssh_bad_exit_status_muted
) do |type, data|
handle_comm(type, data)
end
end
end
end
# This provisions using WinRM, which assumes a PowerShell
# console on the other side.
def provision_winrm(args)

View File

@ -0,0 +1,525 @@
require File.expand_path("../../../../base", __FILE__)
require Vagrant.source_root.join("plugins/communicators/winssh/communicator")
describe VagrantPlugins::CommunicatorWinSSH::Communicator do
include_context "unit"
let(:export_command_template){ 'export %ENV_KEY%="%ENV_VALUE%"' }
let(:ssh) do
double("ssh",
timeout: 1,
host: nil,
port: 5986,
guest_port: 5986,
keep_alive: false
)
end
# SSH configuration information mock
let(:winssh) do
double("winssh",
insert_key: false,
export_command_template: export_command_template,
shell: 'cmd'
)
end
# Configuration mock
let(:config) { double("config", winssh: winssh, ssh: ssh) }
# Provider mock
let(:provider) { double("provider") }
# UI mock
let(:ui) { double("ui") }
# Machine mock built with previously defined
let(:machine) do
double("machine",
config: config,
provider: provider,
ui: ui
)
end
# Subject instance to test
let(:communicator){ @communicator ||= described_class.new(machine) }
# Underlying net-ssh connection mock
let(:connection) { double("connection") }
# Base net-ssh connection channel mock
let(:channel) { double("channel") }
# net-ssh connection channel mock for running commands
let(:command_channel) { double("command_channel") }
# Default exit data for commands run
let(:exit_data) { double("exit_data", read_long: 0) }
# Marker used for flagging start of output
let(:command_garbage_marker) { communicator.class.const_get(:CMD_GARBAGE_MARKER) }
# Start marker output when PTY is enabled
let(:pty_delim_start) { communicator.class.const_get(:PTY_DELIM_START) }
# End marker output when PTY is enabled
let(:pty_delim_end) { communicator.class.const_get(:PTY_DELIM_END) }
# Command output returned on stdout
let(:command_stdout_data) { '' }
# Command output returned on stderr
let(:command_stderr_data) { '' }
# Mock for net-ssh scp
let(:scp) { double("scp") }
# Stub file to match commands
let(:ssh_cmd_file){ double("ssh_cmd_file", path: "/dev/null/path") }
# Setup for commands using the net-ssh connection. This can be reused where needed
# by providing to `before`
connection_setup = proc do
allow(connection).to receive(:logger)
allow(connection).to receive(:closed?).and_return false
allow(connection).to receive(:open_channel).
and_yield(channel).and_return(channel)
allow(channel).to receive(:wait).and_return true
allow(channel).to receive(:close)
allow(command_channel).to receive(:send_data)
allow(command_channel).to receive(:eof!)
allow(command_channel).to receive(:on_data).
and_yield(nil, command_stdout_data)
allow(command_channel).to receive(:on_extended_data).
and_yield(nil, nil, command_stderr_data)
allow(machine).to receive(:ssh_info).and_return(host: '10.1.2.3', port: 22)
allow(channel).to receive(:[]=).with(any_args).and_return(true)
allow(channel).to receive(:on_close)
allow(channel).to receive(:on_data)
allow(channel).to receive(:on_extended_data)
allow(channel).to receive(:on_request)
allow(channel).to receive(:on_process)
allow(channel).to receive(:exec).with(anything).
and_yield(command_channel, '').and_return channel
expect(command_channel).to receive(:on_request).with('exit-status').
and_yield(nil, exit_data)
# Return mocked net-ssh connection during setup
allow(communicator).to receive(:retryable).and_return(connection)
allow(Tempfile).to receive(:new).with(/vagrant-ssh/).and_return(ssh_cmd_file)
allow(ssh_cmd_file).to receive(:puts)
allow(ssh_cmd_file).to receive(:close)
allow(ssh_cmd_file).to receive(:delete)
allow(scp).to receive(:upload!)
allow(communicator).to receive(:scp_connect).and_return(true)
end
describe ".wait_for_ready" do
before(&connection_setup)
context "with no static config (default scenario)" do
before do
allow(ui).to receive(:detail)
end
context "when ssh_info requires a multiple tries before it is ready" do
before do
expect(machine).to receive(:ssh_info).
and_return(nil).ordered
expect(machine).to receive(:ssh_info).
and_return(host: '10.1.2.3', port: 22).ordered
end
it "retries ssh_info until ready" do
# retries are every 0.5 so buffer the timeout just a hair over
expect(communicator.wait_for_ready(0.6)).to eq(true)
end
end
end
end
describe ".ready?" do
before(&connection_setup)
it "returns true if shell test is successful" do
expect(communicator.ready?).to be_true
end
context "with an invalid shell test" do
before do
expect(exit_data).to receive(:read_long).and_return 1
end
it "returns raises SSHInvalidShell error" do
expect{ communicator.ready? }.to raise_error Vagrant::Errors::SSHInvalidShell
end
end
end
describe ".execute" do
before(&connection_setup)
it "runs valid command and returns successful status code" do
expect(ssh_cmd_file).to receive(:puts).with(/dir/)
expect(communicator.execute("dir")).to eq(0)
end
it "prepends UUID output to command for garbage removal" do
expect(ssh_cmd_file).to receive(:puts).
with(/ECHO OFF\nECHO #{command_garbage_marker}\nECHO #{command_garbage_marker}.*/)
expect(communicator.execute("dir")).to eq(0)
end
context "with command returning an error" do
let(:exit_data) { double("exit_data", read_long: 1) }
it "raises error when exit-code is non-zero" do
expect(ssh_cmd_file).to receive(:puts).with(/dir/)
expect{ communicator.execute("dir") }.to raise_error(Vagrant::Errors::VagrantError)
end
it "returns exit-code when exit-code is non-zero and error check is disabled" do
expect(ssh_cmd_file).to receive(:puts).with(/dir/)
expect(communicator.execute("dir", error_check: false)).to eq(1)
end
end
context "with garbage content prepended to command output" do
let(:command_stdout_data) do
"Line of garbage\nMore garbage\n#{command_garbage_marker}Dir1\nDir2\n"
end
it "removes any garbage output prepended to command output" do
stdout = ''
expect(
communicator.execute("dir") do |type, data|
if type == :stdout
stdout << data
end
end
).to eq(0)
expect(stdout).to eq("Dir1\nDir2\n")
end
end
context "with garbage content prepended to command stderr output" do
let(:command_stderr_data) do
"Line of garbage\nMore garbage\n#{command_garbage_marker}Dir1\nDir2\n"
end
it "removes any garbage output prepended to command stderr output" do
stderr = ''
expect(
communicator.execute("dir") do |type, data|
if type == :stderr
stderr << data
end
end
).to eq(0)
expect(stderr).to eq("Dir1\nDir2\n")
end
end
end
describe ".test" do
before(&connection_setup)
context "with exit code as zero" do
it "returns true" do
expect(communicator.test("dir")).to be_true
end
end
context "with exit code as non-zero" do
before do
expect(exit_data).to receive(:read_long).and_return 1
end
it "returns false" do
expect(communicator.test("false.exe")).to be_false
end
end
end
describe ".upload" do
before do
expect(communicator).to receive(:scp_connect).and_yield(scp)
end
it "uploads a directory if local path is a directory" do
Dir.mktmpdir('vagrant-test') do |dir|
expect(scp).to receive(:upload!).with(dir, 'C:\destination', recursive: true)
communicator.upload(dir, 'C:\destination')
end
end
it "uploads a file if local path is a file" do
file = Tempfile.new('vagrant-test')
begin
expect(scp).to receive(:upload!).with(instance_of(File), 'C:\destination\file')
communicator.upload(file.path, 'C:\destination\file')
ensure
file.delete
end
end
it "raises custom error on permission errors" do
file = Tempfile.new('vagrant-test')
begin
expect(scp).to receive(:upload!).with(instance_of(File), 'C:\destination\file').
and_raise("Permission denied")
expect{ communicator.upload(file.path, 'C:\destination\file') }.to(
raise_error(Vagrant::Errors::SCPPermissionDenied)
)
ensure
file.delete
end
end
it "does not raise custom error on non-permission errors" do
file = Tempfile.new('vagrant-test')
begin
expect(scp).to receive(:upload!).with(instance_of(File), 'C:\destination\file').
and_raise("Some other error")
expect{ communicator.upload(file.path, 'C:\destination\file') }.to raise_error(RuntimeError)
ensure
file.delete
end
end
end
describe ".download" do
before do
expect(communicator).to receive(:scp_connect).and_yield(scp)
end
it "calls scp to download file" do
expect(scp).to receive(:download!).with('/path/from', 'C:\path\to')
communicator.download('/path/from', 'C:\path\to')
end
end
describe ".connect" do
it "cannot be called directly" do
expect{ communicator.connect }.to raise_error(NoMethodError)
end
context "with default configuration" do
before do
expect(machine).to receive(:ssh_info).and_return(
host: nil,
port: nil,
private_key_path: nil,
username: nil,
password: nil,
keys_only: true,
paranoid: false
)
end
it "has keys_only enabled" do
expect(Net::SSH).to receive(:start).with(
nil, nil, hash_including(
keys_only: true
)
).and_return(true)
communicator.send(:connect)
end
it "has paranoid disabled" do
expect(Net::SSH).to receive(:start).with(
nil, nil, hash_including(
paranoid: false
)
).and_return(true)
communicator.send(:connect)
end
it "does not include any private key paths" do
expect(Net::SSH).to receive(:start).with(
nil, nil, hash_excluding(
keys: anything
)
).and_return(true)
communicator.send(:connect)
end
it "includes `none` and `hostbased` auth methods" do
expect(Net::SSH).to receive(:start).with(
nil, nil, hash_including(
auth_methods: ["none", "hostbased"]
)
).and_return(true)
communicator.send(:connect)
end
end
context "with keys_only disabled and paranoid enabled" do
before do
expect(machine).to receive(:ssh_info).and_return(
host: nil,
port: nil,
private_key_path: nil,
username: nil,
password: nil,
keys_only: false,
paranoid: true
)
end
it "has keys_only enabled" do
expect(Net::SSH).to receive(:start).with(
nil, nil, hash_including(
keys_only: false
)
).and_return(true)
communicator.send(:connect)
end
it "has paranoid disabled" do
expect(Net::SSH).to receive(:start).with(
nil, nil, hash_including(
paranoid: true
)
).and_return(true)
communicator.send(:connect)
end
end
context "with host and port configured" do
before do
expect(machine).to receive(:ssh_info).and_return(
host: '127.0.0.1',
port: 2222,
private_key_path: nil,
username: nil,
password: nil,
keys_only: true,
paranoid: false
)
end
it "specifies configured host" do
expect(Net::SSH).to receive(:start).with("127.0.0.1", anything, anything)
communicator.send(:connect)
end
it "has port defined" do
expect(Net::SSH).to receive(:start).with("127.0.0.1", anything, hash_including(port: 2222))
communicator.send(:connect)
end
end
context "with private_key_path configured" do
before do
expect(machine).to receive(:ssh_info).and_return(
host: '127.0.0.1',
port: 2222,
private_key_path: ['/priv/key/path'],
username: nil,
password: nil,
keys_only: true,
paranoid: false
)
end
it "includes private key paths" do
expect(Net::SSH).to receive(:start).with(
anything, anything, hash_including(
keys: ["/priv/key/path"]
)
).and_return(true)
communicator.send(:connect)
end
it "includes `publickey` auth method" do
expect(Net::SSH).to receive(:start).with(
anything, anything, hash_including(
auth_methods: ["none", "hostbased", "publickey"]
)
).and_return(true)
communicator.send(:connect)
end
end
context "with username and password configured" do
before do
expect(machine).to receive(:ssh_info).and_return(
host: '127.0.0.1',
port: 2222,
private_key_path: nil,
username: 'vagrant',
password: 'vagrant',
keys_only: true,
paranoid: false
)
end
it "has username defined" do
expect(Net::SSH).to receive(:start).with(anything, 'vagrant', anything).and_return(true)
communicator.send(:connect)
end
it "has password defined" do
expect(Net::SSH).to receive(:start).with(
anything, anything, hash_including(
password: 'vagrant'
)
).and_return(true)
communicator.send(:connect)
end
it "includes `password` auth method" do
expect(Net::SSH).to receive(:start).with(
anything, anything, hash_including(
auth_methods: ["none", "hostbased", "password"]
)
).and_return(true)
communicator.send(:connect)
end
end
context "with password and private_key_path configured" do
before do
expect(machine).to receive(:ssh_info).and_return(
host: '127.0.0.1',
port: 2222,
private_key_path: ['/priv/key/path'],
username: 'vagrant',
password: 'vagrant',
keys_only: true,
paranoid: false
)
end
it "has password defined" do
expect(Net::SSH).to receive(:start).with(
anything, anything, hash_including(
password: 'vagrant'
)
).and_return(true)
communicator.send(:connect)
end
it "includes private key paths" do
expect(Net::SSH).to receive(:start).with(
anything, anything, hash_including(
keys: ["/priv/key/path"]
)
).and_return(true)
communicator.send(:connect)
end
it "includes `publickey` and `password` auth methods" do
expect(Net::SSH).to receive(:start).with(
anything, anything, hash_including(
auth_methods: ["none", "hostbased", "publickey", "password"]
)
).and_return(true)
communicator.send(:connect)
end
end
end
describe ".generate_environment_export" do
it "should generate bourne shell compatible export" do
communicator.send(:generate_environment_export, "TEST", "value").should eq("export TEST=\"value\"\n")
end
context "with custom template defined" do
let(:export_command_template){ "setenv %ENV_KEY% %ENV_VALUE%" }
it "should generate custom export based on template" do
communicator.send(:generate_environment_export, "TEST", "value").should eq("setenv TEST value\n")
end
end
end
end