Skip to content
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

Merged
merged 26 commits into from
Dec 5, 2017

Conversation

jerryaldrichiii
Copy link
Contributor

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

@jerryaldrichiii jerryaldrichiii requested a review from a team November 21, 2017 19:41
@jerryaldrichiii jerryaldrichiii force-pushed the ja/add-windows-pipe-server branch 7 times, most recently from 3fe7b29 to f2ecf92 Compare November 21, 2017 22:41
@jerryaldrichiii jerryaldrichiii changed the title WIP: Use named pipe to decrease local Windows runtime Use named pipe to decrease local Windows runtime Nov 21, 2017
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')
Copy link
Contributor

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

@jerryaldrichiii
Copy link
Contributor Author

Good catch @chris-rock @adamleff. I have fixed this to append a hex string to the end of the pipe name.

chris-rock
chris-rock previously approved these changes Nov 22, 2017
# temporarily unavailable.
100.times do
begin
pipe = open(pipe_location, 'r+')
Copy link
Contributor

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?

Copy link
Contributor Author

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!

end

def start_named_pipe_server(pipe_name) # rubocop:disable Metrics/MethodLength
require 'win32/process'
Copy link
Contributor

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.

Copy link
Contributor Author

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?

@chris-rock chris-rock dismissed their stale review November 22, 2017 20:42

Changes are required

@jerryaldrichiii jerryaldrichiii force-pushed the ja/add-windows-pipe-server branch from 24946a1 to 140ac78 Compare November 22, 2017 22:32
Copy link
Contributor

@chris-rock chris-rock left a 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.

@pipe = acquire_named_pipe if @platform.windows?
end

def acquire_named_pipe
Copy link
Contributor

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

if defined?(@pipe)
res = run_powershell_using_named_pipe(cmd)
CommandResult.new(res['stdout'], res['stderr'], res['exitstatus'])
else
Copy link
Contributor

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 

Copy link
Contributor Author

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.

@@ -36,6 +67,60 @@ def local?
true
end

def run_powershell_using_named_pipe(script)
Copy link
Contributor

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

end

def start_named_pipe_server(pipe_name)
require 'win32/process'
Copy link
Contributor

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?

Copy link
Contributor Author

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.

@jerryaldrichiii jerryaldrichiii force-pushed the ja/add-windows-pipe-server branch from 6c08b1f to b6a1d51 Compare November 27, 2017 01:15
Copy link
Contributor

@chris-rock chris-rock left a 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.

else
Train::File::Local::Unix.new(self, path)
end
@files[path] ||= if os.windows?
Copy link
Contributor

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

@@ -21,13 +21,19 @@ def initialize(options)
super(options)
@cmd_wrapper = nil
@cmd_wrapper = CommandWrapper.load(self, options)
@platform = platform
Copy link
Contributor

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.

res.run_command
CommandResult.new(res.stdout, res.stderr, res.exitstatus)
if defined?(@platform) && @platform.windows?
@windows_runner ||= WindowsRunner.new
Copy link
Contributor

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()

Copy link
Contributor Author

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.

Copy link
Contributor

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 :-)

Copy link
Contributor Author

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.

end

def run_command(cmd)
res = @pipe ? run_via_pipe(cmd) : run_via_shellout(cmd)
Copy link
Contributor

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
Copy link
Contributor

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

Copy link
Contributor Author

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.

Copy link
Contributor

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!

@windows_runner ||= WindowsRunner.new
@windows_runner.run_command(cmd)
else
cmd = @cmd_wrapper.run(cmd) unless @cmd_wrapper.nil?
Copy link
Contributor

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?

res.run_command
CommandResult.new(res.stdout, res.stderr, res.exitstatus)
if defined?(@os) && @os.windows?
@windows_runner ||= WindowsRunner.new
Copy link
Contributor

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

end
end

class WindowsRunner
Copy link
Contributor

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
Copy link
Contributor

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>
@jerryaldrichiii jerryaldrichiii force-pushed the ja/add-windows-pipe-server branch from 3f7c18e to e7d7d32 Compare November 27, 2017 18:24
Copy link
Contributor

@chris-rock chris-rock left a 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

Copy link
Contributor

@adamleff adamleff left a 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.

private

def acquire_pipe
current_pipe = Dir.entries('//./pipe/').find { |f| f =~ /inspec_/ }
Copy link
Contributor

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

Copy link
Contributor Author

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>
Copy link
Contributor

@adamleff adamleff left a 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.

@@ -40,6 +40,33 @@
cmd.stderr.must_equal ''
end

it 'uses a named pipe if available' do
Copy link
Contributor

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?

Copy link
Contributor Author

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.

cmd.stderr.must_equal ''
end

it 'when named pipe is not available it runs `Mixlib::Shellout`' do
Copy link
Contributor

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?

Copy link
Contributor Author

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>
@jerryaldrichiii jerryaldrichiii force-pushed the ja/add-windows-pipe-server branch from f3594f5 to dd5cdde Compare November 30, 2017 17:59
Signed-off-by: Jerry Aldrich <jerryaldrichiii@gmail.com>
@jerryaldrichiii jerryaldrichiii force-pushed the ja/add-windows-pipe-server branch from dd5cdde to ab34ccc Compare November 30, 2017 18:02
Signed-off-by: Jerry Aldrich <jerryaldrichiii@gmail.com>
jerryaldrichiii and others added 3 commits December 1, 2017 17:25
Signed-off-by: Jerry Aldrich <jerryaldrichiii@gmail.com>
Signed-off-by: Jared Quick <jquick@chef.io>
Signed-off-by: Jerry Aldrich <jerryaldrichiii@gmail.com>
Copy link
Contributor

@adamleff adamleff left a 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.


describe 'when running on Windows' do
let(:connection) { TransportHelper.new(:windows).transport.connection }

Copy link
Contributor

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 lets together than space them out.

plat.add_platform_methods
plat.stubs(:windows?).returns(true) if type == :windows
Copy link
Contributor

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?

Copy link
Contributor Author

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>
Copy link
Contributor

@adamleff adamleff left a 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.

Train::Platforms::Detect::Specifications::OS.load
plat = Train::Platforms.name('mock')
plat = Train::Platforms.name(opts[:platform_name])
plat.family_hierarchy = opts[:family_hierarchy]
Copy link
Contributor

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?

Copy link
Contributor Author

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?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants