Skip to content

Commit

Permalink
Use named pipe to decrease local Windows runtime
Browse files Browse the repository at this point in the history
This uses PowerShell to spawn a process that listens on a named pipe for
Base64 encoded commands and executes them. This drastically decreases
the total runtime when running locally on Windows by using a single
PowerShell session to execute commands instead of spawning a new session
for each command.

Huge thanks to @sdelano for the initial idea!

Signed-off-by: Jerry Aldrich <jerryaldrichiii@gmail.com>
  • Loading branch information
jerryaldrichiii committed Nov 21, 2017
1 parent c3fd09d commit 3fe7b29
Show file tree
Hide file tree
Showing 2 changed files with 105 additions and 46 deletions.
41 changes: 0 additions & 41 deletions lib/train/extras/command_wrapper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -121,43 +121,6 @@ def safe_string(str)
end
end

# this is required if you run locally on windows,
# winrm connections provide a PowerShell shell automatically
# TODO: only activate in local mode
class PowerShellCommand < CommandWrapperBase
Train::Options.attach(self)

def initialize(backend, options)
@backend = backend
validate_options(options)
end

def run(script)
# wrap the script to ensure we always run it via powershell
# especially in local mode, we cannot be sure that we get a Powershell
# we may just get a `cmd`.
# TODO: we may want to opt for powershell.exe -command instead of `encodeCommand`
"powershell -NoProfile -encodedCommand #{encoded(safe_script(script))}"
end

# suppress the progress stream from leaking to stderr
def safe_script(script)
"$ProgressPreference='SilentlyContinue';" + script
end

# Encodes the script so that it can be passed to the PowerShell
# --EncodedCommand argument.
# @return [String] The UTF-16LE base64 encoded script
def encoded(script)
encoded_script = safe_script(script).encode('UTF-16LE', 'UTF-8')
Base64.strict_encode64(encoded_script)
end

def to_s
'PowerShell CommandWrapper'
end
end

class CommandWrapper
include_options LinuxCommand

Expand All @@ -168,10 +131,6 @@ def self.load(transport, options)
msg = res.verify
fail Train::UserError, "Sudo failed: #{msg}" unless msg.nil?
res
# only use powershell command for local transport. winrm transport
# uses powershell as default
elsif transport.os.windows? && transport.class == Train::Transports::Local::Connection
PowerShellCommand.new(transport, options)
end
end
end
Expand Down
110 changes: 105 additions & 5 deletions lib/train/transports/local.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,18 +16,27 @@ def connection(_ = nil)
@connection ||= Connection.new(@options)
end

class Connection < BaseConnection
class Connection < BaseConnection # rubocop:disable Metrics/ClassLength
require 'json'
require 'base64'

def initialize(options)
super(options)
@cmd_wrapper = nil
@cmd_wrapper = CommandWrapper.load(self, options)
end

def run_command(cmd)
cmd = @cmd_wrapper.run(cmd) unless @cmd_wrapper.nil?
res = Mixlib::ShellOut.new(cmd)
res.run_command
CommandResult.new(res.stdout, res.stderr, res.exitstatus)
if defined?(@platform) && @platform.windows?
start_named_pipe_server unless File.exist?('//localhost/pipe/InSpec')
res = run_powershell_using_named_pipe(cmd)
CommandResult.new(res['stdout'], res['stderr'], res['exitstatus'])
else
cmd = @cmd_wrapper.run(cmd) unless @cmd_wrapper.nil?
res = Mixlib::ShellOut.new(cmd)
res.run_command
CommandResult.new(res.stdout, res.stderr, res.exitstatus)
end
rescue Errno::ENOENT => _
CommandResult.new('', '', 1)
end
Expand All @@ -36,6 +45,97 @@ def local?
true
end

def run_powershell_using_named_pipe(script)
pipe = nil
# Try to acquire pipe for 10 seconds with 0.1 second intervals.
# Removing this can result in instability due to the pipe being
# temporarily unavailable.
100.times do
begin
pipe = open('//localhost/pipe/InSpec', 'r+')
break
rescue
sleep 0.1
end
end
fail 'Could not open pipe `//localhost/pipe/InSpec`' if pipe.nil?
# Prevent progress stream from leaking to stderr
script = "$ProgressPreference='SilentlyContinue';" + script
encoded_script = Base64.strict_encode64(script)
pipe.puts(encoded_script)
pipe.flush
result = JSON.parse(Base64.decode64(pipe.readline))
pipe.close
result
end

def start_named_pipe_server # rubocop:disable Metrics/MethodLength
require 'win32/process'

script = <<-EOF
$ProgressPreference = 'SilentlyContinue'
$ErrorActionPreference = 'Stop'
Function Execute-UserCommand($userInput) {
$command = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($userInput))
$scriptBlock = $ExecutionContext.InvokeCommand.NewScriptBlock($command)
try {
$stdout = & $scriptBlock | Out-String
$result = @{ 'stdout' = $stdout ; 'stderr' = ''; 'exitstatus' = 0 }
} catch {
$stderr = $_ | Out-String
$result = @{ 'stdout' = ''; 'stderr' = $_; 'exitstatus' = 1 }
}
return $result | ConvertTo-JSON
}
Function Start-PipeServer {
while($true) {
# Attempt to acquire a pipe for 10 seconds, trying every 100 milliseconds
for($i=1; $i -le 100; $i++) {
try {
$pipeServer = New-Object System.IO.Pipes.NamedPipeServerStream('InSpec', [System.IO.Pipes.PipeDirection]::InOut)
break
} catch {
Start-Sleep -m 100
if($i -eq 100) { throw }
}
}
$pipeReader = New-Object System.IO.StreamReader($pipeServer)
$pipeWriter = New-Object System.IO.StreamWriter($pipeServer)
$pipeServer.WaitForConnection()
$pipeWriter.AutoFlush = $true
$clientConnected = $true
while($clientConnected) {
$input = $pipeReader.ReadLine()
if($input -eq $null) {
$clientConnected = $false
$pipeServer.Dispose()
} else {
$result = Execute-UserCommand($input)
$encodedResult = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($result))
$pipeWriter.WriteLine($encodedResult)
}
}
}
}
Start-PipeServer
EOF

utf8_script = script.encode('UTF-16LE', 'UTF-8')
base64_script = Base64.strict_encode64(utf8_script)
cmd = "powershell -noprofile -executionpolicy bypass -noninteractive -encodedcommand #{base64_script}"

server_pid = Process.create(command_line: cmd).process_id

# Ensure process is killed when the Train process exits
at_exit { Process.kill('KILL', server_pid) }
end

def file(path)
@files[path] ||= \
if os.windows?
Expand Down

0 comments on commit 3fe7b29

Please sign in to comment.