-
Notifications
You must be signed in to change notification settings - Fork 90
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Use named pipe to decrease local Windows runtime #220
Conversation
3fe7b29
to
f2ecf92
Compare
lib/train/transports/local.rb
Outdated
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') |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As highlighted by @adamleff we should make the pipe unique
Good catch @chris-rock @adamleff. I have fixed this to append a hex string to the end of the pipe name. |
lib/train/transports/local.rb
Outdated
# temporarily unavailable. | ||
100.times do | ||
begin | ||
pipe = open(pipe_location, 'r+') |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do we need to close the pipe?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Interesting idea...after consulting with @jquick I think we should be able to do that. I'll get to work!
lib/train/transports/local.rb
Outdated
end | ||
|
||
def start_named_pipe_server(pipe_name) # rubocop:disable Metrics/MethodLength | ||
require 'win32/process' |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we implement a fallback for windows platforms where this is not available, eg. windows nano?, in this case we have powershell available already.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there a way I can identify Nano with current Train? If not I can fallback to not creating a pipe if I can't open one. Thoughts?
24946a1
to
140ac78
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@jerryaldrichiii I think we get closer and closer. Great work. I added some questions.
lib/train/transports/local.rb
Outdated
@pipe = acquire_named_pipe if @platform.windows? | ||
end | ||
|
||
def acquire_named_pipe |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think that should be a private function
lib/train/transports/local.rb
Outdated
if defined?(@pipe) | ||
res = run_powershell_using_named_pipe(cmd) | ||
CommandResult.new(res['stdout'], res['stderr'], res['exitstatus']) | ||
else |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this is problematic, we check if a pipe is available. Lets assume we are on a windows platform and pipe creation failed, then we need to implement a fallback. Essentially we need to decide:
if os.windows?
if pipe
# use pipe
else
# fallback, which may be slower but that is okay
end
else
# linux / mac / unix
end
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'll bring back the old functionality and then wrap acquire_named_pipe
in a begin/rescue
. That way if the pipe fails for whatever reason, it will default to the way things run now.
lib/train/transports/local.rb
Outdated
@@ -36,6 +67,60 @@ def local? | |||
true | |||
end | |||
|
|||
def run_powershell_using_named_pipe(script) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
run and start pipe need to be private functions
lib/train/transports/local.rb
Outdated
end | ||
|
||
def start_named_pipe_server(pipe_name) | ||
require 'win32/process' |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there a reason why we are not using mixlib-shellout, since we just start a powershell command?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm using win32/process
to spawn a background process. Can I use mixlib-shellout
to start a background process?
Since the PowerShell server script never returns (essentially a daemon) I'm not sure I can use mixlib-shellout
.
6c08b1f
to
b6a1d51
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thank you for the great fallback implementation @jerryaldrichiii I proposed some further improvements to make the code even more clean.
lib/train/transports/local.rb
Outdated
else | ||
Train::File::Local::Unix.new(self, path) | ||
end | ||
@files[path] ||= if os.windows? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is rewritten by Jared, i would just keep the original to avoid merge conflicts
lib/train/transports/local.rb
Outdated
@@ -21,13 +21,19 @@ def initialize(options) | |||
super(options) | |||
@cmd_wrapper = nil | |||
@cmd_wrapper = CommandWrapper.load(self, options) | |||
@platform = platform |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think should stick to os or platform, but not both since they are referring to the same object.
lib/train/transports/local.rb
Outdated
res.run_command | ||
CommandResult.new(res.stdout, res.stderr, res.exitstatus) | ||
if defined?(@platform) && @platform.windows? | ||
@windows_runner ||= WindowsRunner.new |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The WindowsRunner
is a great step in the right direction. Should we do this analog to our file handling? eg.
Train::Command::Local::UnixRunner.new()
Train::Command::Local::WindowsPipeRunner.new()
# Fallback when the pipe is not working
Train::Command::Local::WindowsShellRunner.new()
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I like the idea of separating the Windows runners. I don't see a reason for a UnixRunner
at this time. I may create a GenericRunner
which will use ShellOut
and be used by Linux and the first few commands that determine the OS.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The unixrunner was the default shell out :-)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yup, but a few commands are ran using ShellOut while the OS is being identified, that's why I opted for GenericRunner
.
lib/train/transports/local.rb
Outdated
end | ||
|
||
def run_command(cmd) | ||
res = @pipe ? run_via_pipe(cmd) : run_via_shellout(cmd) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would split the runner into two, so that we have a WindowsPipeRunner
and a WindowsShellRunner
. This would make the implementation more clean. It also allows us later to run the pipe only on supported windows systems.
require 'securerandom' | ||
|
||
def initialize | ||
@pipe = acquire_pipe |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I would just throw a train exception that can be handled and we have a proper fallback, which would be the WindowsShellRunner
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agreed! I make it so in the latest commits.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So much cleaner now, thank you!
lib/train/transports/local.rb
Outdated
@windows_runner ||= WindowsRunner.new | ||
@windows_runner.run_command(cmd) | ||
else | ||
cmd = @cmd_wrapper.run(cmd) unless @cmd_wrapper.nil? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should we move the command wrapper handling into the GenericRunner now?
lib/train/transports/local.rb
Outdated
res.run_command | ||
CommandResult.new(res.stdout, res.stderr, res.exitstatus) | ||
if defined?(@os) && @os.windows? | ||
@windows_runner ||= WindowsRunner.new |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
should we decide for the runner during initialization? we could just can @runner.run_command(cmd)
then here
lib/train/transports/local.rb
Outdated
end | ||
end | ||
|
||
class WindowsRunner |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
if we move the creation to the initialize phase of Connection
, I think we do not need the WindowsRunner
abstraction.
require 'securerandom' | ||
|
||
def initialize | ||
@pipe = acquire_pipe |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So much cleaner now, thank you!
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>
Signed-off-by: Jerry Aldrich <jerryaldrichiii@gmail.com>
Signed-off-by: Jerry Aldrich <jerryaldrichiii@gmail.com>
Signed-off-by: Jerry Aldrich <jerryaldrichiii@gmail.com>
Signed-off-by: Jerry Aldrich <jerryaldrichiii@gmail.com>
Signed-off-by: Jerry Aldrich <jerryaldrichiii@gmail.com>
Signed-off-by: Jerry Aldrich <jerryaldrichiii@gmail.com>
Signed-off-by: Jerry Aldrich <jerryaldrichiii@gmail.com>
Signed-off-by: Jerry Aldrich <jerryaldrichiii@gmail.com>
Signed-off-by: Jerry Aldrich <jerryaldrichiii@gmail.com>
Signed-off-by: Jerry Aldrich <jerryaldrichiii@gmail.com>
Signed-off-by: Jerry Aldrich <jerryaldrichiii@gmail.com>
Signed-off-by: Jerry Aldrich <jerryaldrichiii@gmail.com>
Signed-off-by: Jerry Aldrich <jerryaldrichiii@gmail.com>
Signed-off-by: Jerry Aldrich <jerryaldrichiii@gmail.com>
Signed-off-by: Jerry Aldrich <jerryaldrichiii@gmail.com>
3f7c18e
to
e7d7d32
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thank you @jerryaldrichiii That looks good to me
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@jerryaldrichiii this is pretty great, thanks for the good work on this.
I'm a little concerned about a potential race condition, further details below.
lib/train/transports/local.rb
Outdated
private | ||
|
||
def acquire_pipe | ||
current_pipe = Dir.entries('//./pipe/').find { |f| f =~ /inspec_/ } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm a little concerned about this logic. What if there's another InSpec running on the host at the same time this one attempts to acquire a pipe? What if there's two? We could potentially find a pipe that's not ours and start using it only to have it disappear between the time we've acquired it and the time we go to use it.
Maybe my lack of knowledge around windows and named pipes is misleading my judgment here... but wouldn't it be better if we stored the name of the pipe we intend to use, look for it when we want to use it, and then if it's not there or not usable for some reason, create it? Some half-baked code to illustrate my idea...
def pipe_name
@pipe_name ||= "inspec_#{SecureRandom.hex}"
end
def reset_pipe_name
@pipe_name = nil
end
def acquire_pipe
pipe = open("//./pipe/#{pipe_name}", ...)
rescue
create_pipe
end
def create_pipe
# don't try to create a pipe of the same name if it exists but we can't open it
reset_pipe_name if File.exist?(pipe_name)
# do the creation and stuff, return nil if we can't create
end
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't like the logic either.
Good news, while trying to implement your solution I realized something. We don't need to open an existing pipe! The acquire_pipe
method only gets called once per object so there is no need to attempt to open an existing pipe. The pipe shouldn't be shared between separate processes in any scenario.
I'll get that fixed.
A pipe is opened only once per object, multiple processes shouldn't share the same pipe because it could be terminated by the other process. Signed-off-by: Jerry Aldrich <jerryaldrichiii@gmail.com>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@jerryaldrichiii the implementation of this looks good, however I have questions about the tests.
test/windows/local_test.rb
Outdated
@@ -40,6 +40,33 @@ | |||
cmd.stderr.must_equal '' | |||
end | |||
|
|||
it 'uses a named pipe if available' do |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't see anything obvious in this test that actually tests that the named pipe is used. How are you asserting that in this test?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Great point, I added words to the comments to make it more clear. Basically, it runs a PowerShell command and checks if the pipe was created by looking for the file that would be created.
test/windows/local_test.rb
Outdated
cmd.stderr.must_equal '' | ||
end | ||
|
||
it 'when named pipe is not available it runs `Mixlib::Shellout`' do |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't see anything obvious in this test that actually tests that Mixlib::ShellOut is used instead of the named pipe. How are you asserting that in this test?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Great point, I added words to the comments to make it more clear. Basically, it runs a PowerShell command and that command checks to see if a pipe exists. If it does then we know it used the pipe and not Mixlib::ShellOut
.
Signed-off-by: Jerry Aldrich <jerryaldrichiii@gmail.com>
Signed-off-by: Jerry Aldrich <jerryaldrichiii@gmail.com>
f3594f5
to
dd5cdde
Compare
Signed-off-by: Jerry Aldrich <jerryaldrichiii@gmail.com>
dd5cdde
to
ab34ccc
Compare
Signed-off-by: Jerry Aldrich <jerryaldrichiii@gmail.com>
a8071fb
to
824f2e3
Compare
Signed-off-by: Jerry Aldrich <jerryaldrichiii@gmail.com>
Signed-off-by: Jared Quick <jquick@chef.io>
Signed-off-by: Jerry Aldrich <jerryaldrichiii@gmail.com>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@jerryaldrichiii the tests here look much better and much easier to understand. Just a couple of cleanup items.
test/unit/transports/local_test.rb
Outdated
|
||
describe 'when running on Windows' do | ||
let(:connection) { TransportHelper.new(:windows).transport.connection } | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Remove extra newline - it's more common to group all the let
s together than space them out.
test/unit/transports/local_test.rb
Outdated
plat.add_platform_methods | ||
plat.stubs(:windows?).returns(true) if type == :windows |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The seems a bit brute-force. Can we accept an options hash and then we can plumb the family in as necessary and let the Train::Platforms
stuff work normally without stubbing methods on this?
def initialize(opts = {})
Train::Platforms::Detect::Specifications::OS.load
plat = Train::Platforms.name('mock')
plat.in_family(opts[:family]) unless opts[:family].nil?
plat.add_platform_methods
end
Would that work?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sure will! Great idea. See latest commit.
Signed-off-by: Jerry Aldrich <jerryaldrichiii@gmail.com>
Signed-off-by: Jerry Aldrich <jerryaldrichiii@gmail.com>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
One possible improvement on the family hierarchy data type, but otherwise, this looks good to me.
test/unit/transports/local_test.rb
Outdated
Train::Platforms::Detect::Specifications::OS.load | ||
plat = Train::Platforms.name('mock') | ||
plat = Train::Platforms.name(opts[:platform_name]) | ||
plat.family_hierarchy = opts[:family_hierarchy] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Does plat.family_hierarchy
need to be an array? The default you have set makes me believe that maybe that's true...
If so, can I suggest changing this to:
plat.family_hierarchy = Array(opts[:family_hierarchy])
... so that it's always an array, even if a future test author supplies a single family string?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Based on a grep
I did I see it defined as an array, but on the mock
transport I see we do a flatten.
https://github.com/chef/train/blob/master/lib/train/transports/mock.rb#L80
I think I'll do what you said, but leave my examples as single item arrays.
Thoughts?
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