summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDaniel DeLeo <dan@opscode.com>2011-12-01 11:51:25 -0800
committerDaniel DeLeo <dan@opscode.com>2011-12-01 11:51:25 -0800
commit1f7734e92f30c0b6545bcc8b537e920e3f12b6bc (patch)
tree0677cc613ef17454cef35c9ce0ad07f50db28603
downloadmixlib-shellout-1f7734e92f30c0b6545bcc8b537e920e3f12b6bc.tar.gz
Initial extraction of ShellOut from Chef
-rw-r--r--.rspec1
-rw-r--r--LICENSE201
-rw-r--r--README.md17
-rw-r--r--Rakefile10
-rw-r--r--lib/mixlib/shellout.rb237
-rw-r--r--lib/mixlib/shellout/exceptions.rb8
-rw-r--r--lib/mixlib/shellout/unix.rb223
-rw-r--r--lib/mixlib/shellout/version.rb5
-rw-r--r--lib/mixlib/shellout/windows.rb554
-rw-r--r--mixlib-shellout.gemspec23
-rw-r--r--spec/mixlib/shellout/shellout_spec.rb466
-rw-r--r--spec/mixlib/spec_helper.rb2
12 files changed, 1747 insertions, 0 deletions
diff --git a/.rspec b/.rspec
new file mode 100644
index 0000000..7060880
--- /dev/null
+++ b/.rspec
@@ -0,0 +1 @@
+-cbfs
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..11069ed
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,201 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+END OF TERMS AND CONDITIONS
+
+APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+Copyright [yyyy] [name of copyright owner]
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..278aa49
--- /dev/null
+++ b/README.md
@@ -0,0 +1,17 @@
+# Mixlib::ShellOut
+Provides a simplified interface to shelling out yet still collecting both
+standard out and standard error and providing full control over environment,
+working directory, uid, gid, etc.
+
+No means for passing input to the subprocess is provided.
+
+## Platform Support
+Mixlib::ShellOut does a standard fork/exec on Unix, and uses the Win32
+API on Windows. There is not currently support for JRuby.
+
+## License
+Apache 2 Licensed. See LICENSE for full details.
+
+## See Also
+* `Process.spawn` in Ruby 1.9
+* [https://github.com/rtomayko/posix-spawn](posix-spawn)
diff --git a/Rakefile b/Rakefile
new file mode 100644
index 0000000..1fb8520
--- /dev/null
+++ b/Rakefile
@@ -0,0 +1,10 @@
+require 'rspec/core/rake_task'
+
+ROOT = File.expand_path(File.dirname(__FILE__))
+
+desc "Run all specs in spec directory"
+RSpec::Core::RakeTask.new(:spec) do |t|
+ t.pattern = FileList['spec/**/*_spec.rb']
+end
+
+task :default => :spec \ No newline at end of file
diff --git a/lib/mixlib/shellout.rb b/lib/mixlib/shellout.rb
new file mode 100644
index 0000000..88a666d
--- /dev/null
+++ b/lib/mixlib/shellout.rb
@@ -0,0 +1,237 @@
+#--
+# Author:: Daniel DeLeo (<dan@opscode.com>)
+# Copyright:: Copyright (c) 2010 Opscode, Inc.
+# License:: Apache License, Version 2.0
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+require 'etc'
+require 'tmpdir'
+require 'chef/log'
+require 'fcntl'
+require 'mixlib/shellout/exceptions'
+
+module Mixlib
+
+ class ShellOut
+ READ_WAIT_TIME = 0.01
+ READ_SIZE = 4096
+ DEFAULT_READ_TIMEOUT = 600
+ DEFAULT_ENVIRONMENT = {'LC_ALL' => 'C'}
+
+ if RUBY_PLATFORM =~ /mswin|mingw32|windows/
+ require 'mixlib/shellout/windows'
+ include ShellOut::Windows
+ else
+ require 'mixlib/shellout/unix'
+ include ShellOut::Unix
+ end
+
+ attr_accessor :user
+ attr_accessor :group
+ attr_accessor :cwd
+ attr_accessor :valid_exit_codes
+ attr_accessor :live_stream
+ attr_accessor :command_log_level
+ attr_accessor :command_log_prepend
+
+ attr_reader :command, :umask, :environment
+ attr_writer :timeout
+ attr_reader :execution_time
+
+ attr_reader :stdout, :stderr, :status
+
+ attr_reader :stdin_pipe, :stdout_pipe, :stderr_pipe, :process_status_pipe
+
+ # === Arguments:
+ # Takes a single command, or a list of command fragments. These are used
+ # as arguments to Kernel.exec. See the Kernel.exec documentation for more
+ # explanation of how arguments are evaluated. The last argument can be an
+ # options Hash.
+ # === Options:
+ # If the last argument is a Hash, it is removed from the list of args passed
+ # to exec and used as an options hash. The following options are available:
+ # * +user+: the user the commmand should run as. if an integer is given, it is
+ # used as a uid. A string is treated as a username and resolved to a uid
+ # with Etc.getpwnam
+ # * +group+: the group the command should run as. works similarly to +user+
+ # * +cwd+: the directory to chdir to before running the command
+ # * +umask+: a umask to set before running the command. If given as an Integer,
+ # be sure to use two leading zeros so it's parsed as Octal. A string will
+ # be treated as an octal integer
+ # * +returns+: one or more Integer values to use as valid exit codes for the
+ # subprocess. This only has an effect if you call +error!+ after
+ # +run_command+.
+ # * +environment+: a Hash of environment variables to set before the command
+ # is run. By default, the environment will *always* be set to 'LC_ALL' => 'C'
+ # to prevent issues with multibyte characters in Ruby 1.8. To avoid this,
+ # use :environment => nil for *no* extra environment settings, or
+ # :environment => {'LC_ALL'=>nil, ...} to set other environment settings
+ # without changing the locale.
+ # * +timeout+: a Numeric value for the number of seconds to wait on the
+ # child process before raising an Exception. This is calculated as the
+ # total amount of time that ShellOut waited on the child process without
+ # receiving any output (i.e., IO.select returned nil). Default is 60
+ # seconds. Note: the stdlib Timeout library is not used.
+ # === Examples:
+ # Invoke find(1) to search for .rb files:
+ # find = Chef::ShellOut.new("find . -name '*.rb'")
+ # find.run_command
+ # # If all went well, the results are on +stdout+
+ # puts find.stdout
+ # # find(1) prints diagnostic info to STDERR:
+ # puts "error messages" + find.stderr
+ # # Raise an exception if it didn't exit with 0
+ # find.error!
+ # Run a command as the +www+ user with no extra ENV settings from +/tmp+
+ # cmd = Chef::ShellOut.new("apachectl", "start", :user => 'www', :env => nil, :cwd => '/tmp')
+ # cmd.run_command # etc.
+ def initialize(*command_args)
+ @stdout, @stderr = '', ''
+ @live_stream = nil
+ @command_log_level = :debug
+ @command_log_prepend = nil
+ @environment = DEFAULT_ENVIRONMENT
+ @cwd = nil
+ @valid_exit_codes = [0]
+
+ if command_args.last.is_a?(Hash)
+ parse_options(command_args.pop)
+ end
+
+ @command = command_args.size == 1 ? command_args.first : command_args
+ end
+
+ def umask=(new_umask)
+ @umask = (new_umask.respond_to?(:oct) ? new_umask.oct : new_umask.to_i) & 007777
+ end
+
+ def uid
+ return nil unless user
+ user.kind_of?(Integer) ? user : Etc.getpwnam(user.to_s).uid
+ end
+
+ def gid
+ return nil unless group
+ group.kind_of?(Integer) ? group : Etc.getgrnam(group.to_s).gid
+ end
+
+ def timeout
+ @timeout || DEFAULT_READ_TIMEOUT
+ end
+
+ # Creates a String showing the output of the command, including a banner
+ # showing the exact command executed. Used by +invalid!+ to show command
+ # results when the command exited with an unexpected status.
+ def format_for_exception
+ msg = ""
+ msg << "---- Begin output of #{command} ----\n"
+ msg << "STDOUT: #{stdout.strip}\n"
+ msg << "STDERR: #{stderr.strip}\n"
+ msg << "---- End output of #{command} ----\n"
+ msg << "Ran #{command} returned #{status.exitstatus}" if status
+ msg
+ end
+
+ def exitstatus
+ @status && @status.exitstatus
+ end
+
+ # Run the command, writing the command's standard out and standard error
+ # to +stdout+ and +stderr+, and saving its exit status object to +status+
+ # === Returns
+ # returns +self+; +stdout+, +stderr+, +status+, and +exitstatus+ will be
+ # populated with results of the command
+ # === Raises
+ # * Errno::EACCES when you are not privileged to execute the command
+ # * Errno::ENOENT when the command is not available on the system (or not
+ # in the current $PATH)
+ # * ::CommandTimeout when the command does not complete
+ # within +timeout+ seconds (default: 60s)
+ def run_command
+ if command_log_prepend
+ Chef::Log.send(command_log_level, "#{command_log_prepend} sh(#{@command})")
+ else
+ Chef::Log.send(command_log_level, "sh(#{@command})")
+ end
+ super
+ end
+
+ # Checks the +exitstatus+ against the set of +valid_exit_codes+. If
+ # +exitstatus+ is not in the list of +valid_exit_codes+, calls +invalid!+,
+ # which raises an Exception.
+ # === Returns
+ # nil::: always returns nil when it does not raise
+ # === Raises
+ # ::ShellCommandFailed::: via +invalid!+
+ def error!
+ unless Array(valid_exit_codes).include?(exitstatus)
+ invalid!("Expected process to exit with #{valid_exit_codes.inspect}, but received '#{exitstatus}'")
+ end
+ end
+
+ # Raises a ::ShellCommandFailed exception, appending the
+ # command's stdout, stderr, and exitstatus to the exception message.
+ # === Arguments
+ # +msg+: A String to use as the basis of the exception message. The
+ # default explanation is very generic, providing a more informative message
+ # is highly encouraged.
+ # === Raises
+ # ::ShellCommandFailed always
+ def invalid!(msg=nil)
+ msg ||= "Command produced unexpected results"
+ raise ShellCommandFailed, msg + "\n" + format_for_exception
+ end
+
+ def inspect
+ "<#{self.class.name}##{object_id}: command: '#@command' process_status: #{@status.inspect} " +
+ "stdout: '#{stdout.strip}' stderr: '#{stderr.strip}' child_pid: #{@child_pid.inspect} " +
+ "environment: #{@environment.inspect} timeout: #{timeout} user: #@user group: #@group working_dir: #@cwd >"
+ end
+
+ private
+
+ def parse_options(opts)
+ opts.each do |option, setting|
+ case option.to_s
+ when 'cwd'
+ self.cwd = setting
+ when 'user'
+ self.user = setting
+ when 'group'
+ self.group = setting
+ when 'umask'
+ self.umask = setting
+ when 'timeout'
+ self.timeout = setting
+ when 'returns'
+ self.valid_exit_codes = Array(setting)
+ when 'live_stream'
+ self.live_stream = setting
+ when 'command_log_level'
+ self.command_log_level = setting
+ when 'command_log_prepend'
+ self.command_log_prepend = setting
+ when 'environment', 'env'
+ # passing :environment => nil means don't set any new ENV vars
+ @environment = setting.nil? ? {} : @environment.dup.merge!(setting)
+ else
+ raise InvalidCommandOption, "option '#{option.inspect}' is not a valid option for #{self.class.name}"
+ end
+ end
+ end
+
+
+ end
+end
diff --git a/lib/mixlib/shellout/exceptions.rb b/lib/mixlib/shellout/exceptions.rb
new file mode 100644
index 0000000..417def3
--- /dev/null
+++ b/lib/mixlib/shellout/exceptions.rb
@@ -0,0 +1,8 @@
+module Mixlib
+ class ShellOut
+ class ShellCommandFailed < RuntimeError; end
+ class CommandTimeout < RuntimeError; end
+ class InvalidCommandOption < RuntimeError; end
+ end
+end
+
diff --git a/lib/mixlib/shellout/unix.rb b/lib/mixlib/shellout/unix.rb
new file mode 100644
index 0000000..6e66b2a
--- /dev/null
+++ b/lib/mixlib/shellout/unix.rb
@@ -0,0 +1,223 @@
+module Mixlib
+ class ShellOut
+ module Unix
+
+ # Run the command, writing the command's standard out and standard error
+ # to +stdout+ and +stderr+, and saving its exit status object to +status+
+ # === Returns
+ # returns +self+; +stdout+, +stderr+, +status+, and +exitstatus+ will be
+ # populated with results of the command
+ # === Raises
+ # * Errno::EACCES when you are not privileged to execute the command
+ # * Errno::ENOENT when the command is not available on the system (or not
+ # in the current $PATH)
+ # * Chef::Exceptions::CommandTimeout when the command does not complete
+ # within +timeout+ seconds (default: 60s)
+ def run_command
+ @child_pid = fork_subprocess
+
+ configure_parent_process_file_descriptors
+ propagate_pre_exec_failure
+
+ @result = nil
+ @execution_time = 0
+
+ # Ruby 1.8.7 and 1.8.6 from mid 2009 try to allocate objects during GC
+ # when calling IO.select and IO#read. Some OS Vendors are not interested
+ # in updating their ruby packages (Apple, *cough*) and we *have to*
+ # make it work. So I give you this epic hack:
+ GC.disable
+ until @status
+ ready = IO.select(open_pipes, nil, nil, READ_WAIT_TIME)
+ unless ready
+ @execution_time += READ_WAIT_TIME
+ if @execution_time >= timeout && !@result
+ raise CommandTimeout, "command timed out:\n#{format_for_exception}"
+ end
+ end
+
+ if ready && ready.first.include?(child_stdout)
+ read_stdout_to_buffer
+ end
+ if ready && ready.first.include?(child_stderr)
+ read_stderr_to_buffer
+ end
+
+ unless @status
+ # make one more pass to get the last of the output after the
+ # child process dies
+ if results = Process.waitpid2(@child_pid, Process::WNOHANG)
+ @status = results.last
+ redo
+ end
+ end
+ end
+ self
+ rescue Exception
+ # do our best to kill zombies
+ Process.waitpid2(@child_pid, Process::WNOHANG) rescue nil
+ raise
+ ensure
+ # no matter what happens, turn the GC back on, and hope whatever busted
+ # version of ruby we're on doesn't allocate some objects during the next
+ # GC run.
+ GC.enable
+ close_all_pipes
+ end
+
+ private
+
+ def set_user
+ if user
+ Process.euid = uid
+ Process.uid = uid
+ end
+ end
+
+ def set_group
+ if group
+ Process.egid = gid
+ Process.gid = gid
+ end
+ end
+
+ def set_environment
+ environment.each do |env_var,value|
+ ENV[env_var] = value
+ end
+ end
+
+ def set_umask
+ File.umask(umask) if umask
+ end
+
+ def set_cwd
+ Dir.chdir(cwd) if cwd
+ end
+
+ def initialize_ipc
+ @stdout_pipe, @stderr_pipe, @process_status_pipe = IO.pipe, IO.pipe, IO.pipe
+ @process_status_pipe.last.fcntl(Fcntl::F_SETFD, Fcntl::FD_CLOEXEC)
+ end
+
+ def child_stdout
+ @stdout_pipe[0]
+ end
+
+ def child_stderr
+ @stderr_pipe[0]
+ end
+
+ def child_process_status
+ @process_status_pipe[0]
+ end
+
+ def close_all_pipes
+ child_stdout.close unless child_stdout.closed?
+ child_stderr.close unless child_stderr.closed?
+ child_process_status.close unless child_process_status.closed?
+ end
+
+ # replace stdout, and stderr with pipes to the parent, and close the
+ # reader side of the error marshaling side channel. Close STDIN so when we
+ # exec, the new program will know it's never getting input ever.
+ def configure_subprocess_file_descriptors
+ process_status_pipe.first.close
+
+ # HACK: for some reason, just STDIN.close isn't good enough when running
+ # under ruby 1.9.2, so make it good enough:
+ stdin_reader, stdin_writer = IO.pipe
+ stdin_writer.close
+ STDIN.reopen stdin_reader
+ stdin_reader.close
+
+ stdout_pipe.first.close
+ STDOUT.reopen stdout_pipe.last
+ stdout_pipe.last.close
+
+ stderr_pipe.first.close
+ STDERR.reopen stderr_pipe.last
+ stderr_pipe.last.close
+
+ STDOUT.sync = STDERR.sync = true
+ end
+
+ def configure_parent_process_file_descriptors
+ # Close the sides of the pipes we don't care about
+ stdout_pipe.last.close
+ stderr_pipe.last.close
+ process_status_pipe.last.close
+ # Get output as it happens rather than buffered
+ child_stdout.sync = true
+ child_stderr.sync = true
+
+ true
+ end
+
+ # Some patch levels of ruby in wide use (in particular the ruby 1.8.6 on OSX)
+ # segfault when you IO.select a pipe that's reached eof. Weak sauce.
+ def open_pipes
+ @open_pipes ||= [child_stdout, child_stderr]
+ end
+
+ def read_stdout_to_buffer
+ while chunk = child_stdout.read_nonblock(READ_SIZE)
+ @stdout << chunk
+ @live_stream << chunk if @live_stream
+ end
+ rescue Errno::EAGAIN
+ rescue EOFError
+ open_pipes.delete_at(0)
+ end
+
+ def read_stderr_to_buffer
+ while chunk = child_stderr.read_nonblock(READ_SIZE)
+ @stderr << chunk
+ end
+ rescue Errno::EAGAIN
+ rescue EOFError
+ open_pipes.delete_at(1)
+ end
+
+ def fork_subprocess
+ initialize_ipc
+
+ fork do
+ configure_subprocess_file_descriptors
+
+ set_group
+ set_user
+ set_environment
+ set_umask
+ set_cwd
+
+ begin
+ command.kind_of?(Array) ? exec(*command) : exec(command)
+
+ raise 'forty-two' # Should never get here
+ rescue Exception => e
+ Marshal.dump(e, process_status_pipe.last)
+ process_status_pipe.last.flush
+ end
+ process_status_pipe.last.close unless (process_status_pipe.last.closed?)
+ exit!
+ end
+ end
+
+ # Attempt to get a Marshaled error from the side-channel.
+ # If it's there, un-marshal it and raise. If it's not there,
+ # assume everything went well.
+ def propagate_pre_exec_failure
+ begin
+ e = Marshal.load child_process_status
+ raise(Exception === e ? e : "unknown failure: #{e.inspect}")
+ rescue EOFError # If we get an EOF error, then the exec was successful
+ true
+ ensure
+ child_process_status.close
+ end
+ end
+
+ end
+ end
+end
diff --git a/lib/mixlib/shellout/version.rb b/lib/mixlib/shellout/version.rb
new file mode 100644
index 0000000..1834e03
--- /dev/null
+++ b/lib/mixlib/shellout/version.rb
@@ -0,0 +1,5 @@
+module Mixlib
+ class Shellout
+ VERSION = "1.0.0.rc.0"
+ end
+end
diff --git a/lib/mixlib/shellout/windows.rb b/lib/mixlib/shellout/windows.rb
new file mode 100644
index 0000000..7c79446
--- /dev/null
+++ b/lib/mixlib/shellout/windows.rb
@@ -0,0 +1,554 @@
+#--
+# Author:: Daniel DeLeo (<dan@opscode.com>)
+# Author:: John Keiser (<jkeiser@opscode.com>)
+# Copyright:: Copyright (c) 2011 Opscode, Inc.
+# License:: Apache License, Version 2.0
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+require 'win32/process'
+require 'windows/handle'
+require 'windows/process'
+require 'windows/synchronize'
+
+module Mixlib
+ class ShellOut
+ module Windows
+
+ include ::Windows::Handle
+ include ::Windows::Process
+ include ::Windows::Synchronize
+
+ TIME_SLICE = 0.05
+
+ #--
+ # Missing lots of features from the UNIX version, such as
+ # uid, etc.
+ def run_command
+
+ #
+ # Create pipes to capture stdout and stderr,
+ #
+ stdout_read, stdout_write = IO.pipe
+ stderr_read, stderr_write = IO.pipe
+ open_streams = [ stdout_read, stderr_read ]
+
+ begin
+
+ #
+ # Set cwd, environment, appname, etc.
+ #
+ app_name, command_line = command_to_run
+ create_process_args = {
+ :app_name => app_name,
+ :command_line => command_line,
+ :startup_info => {
+ :stdout => stdout_write,
+ :stderr => stderr_write
+ },
+ :environment => inherit_environment.map { |k,v| "#{k}=#{v}" },
+ :close_handles => false
+ }
+ create_process_args[:cwd] = cwd if cwd
+
+ #
+ # Start the process
+ #
+ process = Process.create(create_process_args)
+ begin
+
+ #
+ # Wait for the process to finish, consuming output as we go
+ #
+ start_wait = Time.now
+ while true
+ wait_status = WaitForSingleObject(process.process_handle, 0)
+ case wait_status
+ when WAIT_OBJECT_0
+ # Get process exit code
+ exit_code = [0].pack('l')
+ unless GetExitCodeProcess(process.process_handle, exit_code)
+ raise get_last_error
+ end
+ @status = ThingThatLooksSortOfLikeAProcessStatus.new
+ @status.exitstatus = exit_code.unpack('l').first
+
+ return self
+ when WAIT_TIMEOUT
+ # Kill the process
+ if (Time.now - start_wait) > timeout
+ raise Chef::Exceptions::CommandTimeout, "command timed out:\n#{format_for_exception}"
+ end
+
+ consume_output(open_streams, stdout_read, stderr_read)
+ else
+ raise "Unknown response from WaitForSingleObject(#{process.process_handle}, #{timeout*1000}): #{wait_status}"
+ end
+
+ end
+
+ ensure
+ CloseHandle(process.thread_handle)
+ CloseHandle(process.process_handle)
+ end
+
+ ensure
+ #
+ # Consume all remaining data from the pipes until they are closed
+ #
+ stdout_write.close
+ stderr_write.close
+
+ while consume_output(open_streams, stdout_read, stderr_read)
+ end
+ end
+ end
+
+ private
+
+ class ThingThatLooksSortOfLikeAProcessStatus
+ attr_accessor :exitstatus
+ end
+
+ def consume_output(open_streams, stdout_read, stderr_read)
+ return false if open_streams.length == 0
+ ready = IO.select(open_streams, nil, nil, READ_WAIT_TIME)
+ return true if ! ready
+
+ if ready.first.include?(stdout_read)
+ begin
+ next_chunk = stdout_read.readpartial(READ_SIZE)
+ @stdout << next_chunk
+ @live_stream << next_chunk if @live_stream
+ rescue EOFError
+ stdout_read.close
+ open_streams.delete(stdout_read)
+ end
+ end
+
+ if ready.first.include?(stderr_read)
+ begin
+ @stderr << stderr_read.readpartial(READ_SIZE)
+ rescue EOFError
+ stderr_read.close
+ open_streams.delete(stderr_read)
+ end
+ end
+
+ return true
+ end
+
+ SHOULD_USE_CMD = /['"<>|&%]|\b(?:assoc|break|call|cd|chcp|chdir|cls|color|copy|ctty|date|del|dir|echo|endlocal|erase|exit|for|ftype|goto|if|lfnfor|lh|lock|md|mkdir|move|path|pause|popd|prompt|pushd|rd|rem|ren|rename|rmdir|set|setlocal|shift|start|time|title|truename|type|unlock|ver|verify|vol)\b/
+
+ def command_to_run
+ if command =~ SHOULD_USE_CMD
+ [ ENV['COMSPEC'], "cmd /c #{command}" ]
+ else
+ [ which(command[0,command.index(/\s/) || command.length]), command ]
+ end
+ end
+
+ def inherit_environment
+ result = {}
+ ENV.each_pair do |k,v|
+ result[k] = v
+ end
+
+ environment.each_pair do |k,v|
+ if v == nil
+ result.delete(k)
+ else
+ result[k] = v
+ end
+ end
+ result
+ end
+
+ def which(cmd)
+ return cmd if File.executable? cmd
+ exts = ENV['PATHEXT'] ? ENV['PATHEXT'].split(';') + [''] : ['']
+ ENV['PATH'].split(File::PATH_SEPARATOR).each do |path|
+ exts.each { |ext|
+ exe = "#{path}/#{cmd}#{ext}"
+ return exe if File.executable? exe
+ }
+ end
+ return nil
+ end
+ end # class
+ end
+end
+
+#
+# Override module Windows::Process.CreateProcess to fix bug when
+# using both app_name and command_line
+#
+module Windows
+ module Process
+ API.new('CreateProcess', 'SPPPLLLPPP', 'B')
+ end
+end
+
+#
+# Override Win32::Process.create to take a proper environment hash
+# so that variables can contain semicolons
+# (submitted patch to owner)
+#
+module Process
+ def create(args)
+ unless args.kind_of?(Hash)
+ raise TypeError, 'Expecting hash-style keyword arguments'
+ end
+
+ valid_keys = %w/
+ app_name command_line inherit creation_flags cwd environment
+ startup_info thread_inherit process_inherit close_handles with_logon
+ domain password
+ /
+
+ valid_si_keys = %/
+ startf_flags desktop title x y x_size y_size x_count_chars
+ y_count_chars fill_attribute sw_flags stdin stdout stderr
+ /
+
+ # Set default values
+ hash = {
+ 'app_name' => nil,
+ 'creation_flags' => 0,
+ 'close_handles' => true
+ }
+
+ # Validate the keys, and convert symbols and case to lowercase strings.
+ args.each{ |key, val|
+ key = key.to_s.downcase
+ unless valid_keys.include?(key)
+ raise ArgumentError, "invalid key '#{key}'"
+ end
+ hash[key] = val
+ }
+
+ si_hash = {}
+
+ # If the startup_info key is present, validate its subkeys
+ if hash['startup_info']
+ hash['startup_info'].each{ |key, val|
+ key = key.to_s.downcase
+ unless valid_si_keys.include?(key)
+ raise ArgumentError, "invalid startup_info key '#{key}'"
+ end
+ si_hash[key] = val
+ }
+ end
+
+ # The +command_line+ key is mandatory unless the +app_name+ key
+ # is specified.
+ unless hash['command_line']
+ if hash['app_name']
+ hash['command_line'] = hash['app_name']
+ hash['app_name'] = nil
+ else
+ raise ArgumentError, 'command_line or app_name must be specified'
+ end
+ end
+
+ # The environment string should be passed as an array of A=B paths, or
+ # as a string of ';' separated paths.
+ if hash['environment']
+ env = hash['environment']
+ if !env.respond_to?(:join)
+ # Backwards compat for ; separated paths
+ env = hash['environment'].split(File::PATH_SEPARATOR)
+ end
+ # The argument format is a series of null-terminated strings, with an additional null terminator.
+ env = env.map { |e| e + "\0" }.join("") + "\0"
+ if hash['with_logon']
+ env = env.multi_to_wide(e)
+ end
+ env = [env].pack('p*').unpack('L').first
+ else
+ env = nil
+ end
+
+ startinfo = [0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]
+ startinfo = startinfo.pack('LLLLLLLLLLLLSSLLLL')
+ procinfo = [0,0,0,0].pack('LLLL')
+
+ # Process SECURITY_ATTRIBUTE structure
+ process_security = 0
+ if hash['process_inherit']
+ process_security = [0,0,0].pack('LLL')
+ process_security[0,4] = [12].pack('L') # sizeof(SECURITY_ATTRIBUTE)
+ process_security[8,4] = [1].pack('L') # TRUE
+ end
+
+ # Thread SECURITY_ATTRIBUTE structure
+ thread_security = 0
+ if hash['thread_inherit']
+ thread_security = [0,0,0].pack('LLL')
+ thread_security[0,4] = [12].pack('L') # sizeof(SECURITY_ATTRIBUTE)
+ thread_security[8,4] = [1].pack('L') # TRUE
+ end
+
+ # Automatically handle stdin, stdout and stderr as either IO objects
+ # or file descriptors. This won't work for StringIO, however.
+ ['stdin', 'stdout', 'stderr'].each{ |io|
+ if si_hash[io]
+ if si_hash[io].respond_to?(:fileno)
+ handle = get_osfhandle(si_hash[io].fileno)
+ else
+ handle = get_osfhandle(si_hash[io])
+ end
+
+ if handle == INVALID_HANDLE_VALUE
+ raise Error, get_last_error
+ end
+
+ # Most implementations of Ruby on Windows create inheritable
+ # handles by default, but some do not. RF bug #26988.
+ bool = SetHandleInformation(
+ handle,
+ HANDLE_FLAG_INHERIT,
+ HANDLE_FLAG_INHERIT
+ )
+
+ raise Error, get_last_error unless bool
+
+ si_hash[io] = handle
+ si_hash['startf_flags'] ||= 0
+ si_hash['startf_flags'] |= STARTF_USESTDHANDLES
+ hash['inherit'] = true
+ end
+ }
+
+ # The bytes not covered here are reserved (null)
+ unless si_hash.empty?
+ startinfo[0,4] = [startinfo.size].pack('L')
+ startinfo[8,4] = [si_hash['desktop']].pack('p*') if si_hash['desktop']
+ startinfo[12,4] = [si_hash['title']].pack('p*') if si_hash['title']
+ startinfo[16,4] = [si_hash['x']].pack('L') if si_hash['x']
+ startinfo[20,4] = [si_hash['y']].pack('L') if si_hash['y']
+ startinfo[24,4] = [si_hash['x_size']].pack('L') if si_hash['x_size']
+ startinfo[28,4] = [si_hash['y_size']].pack('L') if si_hash['y_size']
+ startinfo[32,4] = [si_hash['x_count_chars']].pack('L') if si_hash['x_count_chars']
+ startinfo[36,4] = [si_hash['y_count_chars']].pack('L') if si_hash['y_count_chars']
+ startinfo[40,4] = [si_hash['fill_attribute']].pack('L') if si_hash['fill_attribute']
+ startinfo[44,4] = [si_hash['startf_flags']].pack('L') if si_hash['startf_flags']
+ startinfo[48,2] = [si_hash['sw_flags']].pack('S') if si_hash['sw_flags']
+ startinfo[56,4] = [si_hash['stdin']].pack('L') if si_hash['stdin']
+ startinfo[60,4] = [si_hash['stdout']].pack('L') if si_hash['stdout']
+ startinfo[64,4] = [si_hash['stderr']].pack('L') if si_hash['stderr']
+ end
+
+ if hash['with_logon']
+ logon = multi_to_wide(hash['with_logon'])
+ domain = multi_to_wide(hash['domain'])
+ app = hash['app_name'].nil? ? nil : multi_to_wide(hash['app_name'])
+ cmd = hash['command_line'].nil? ? nil : multi_to_wide(hash['command_line'])
+ cwd = multi_to_wide(hash['cwd'])
+ passwd = multi_to_wide(hash['password'])
+
+ hash['creation_flags'] |= CREATE_UNICODE_ENVIRONMENT
+
+ process_ran = CreateProcessWithLogonW(
+ logon, # User
+ domain, # Domain
+ passwd, # Password
+ LOGON_WITH_PROFILE, # Logon flags
+ app, # App name
+ cmd, # Command line
+ hash['creation_flags'], # Creation flags
+ env, # Environment
+ cwd, # Working directory
+ startinfo, # Startup Info
+ procinfo # Process Info
+ )
+ else
+ process_ran = CreateProcess(
+ hash['app_name'], # App name
+ hash['command_line'], # Command line
+ process_security, # Process attributes
+ thread_security, # Thread attributes
+ hash['inherit'], # Inherit handles?
+ hash['creation_flags'], # Creation flags
+ env, # Environment
+ hash['cwd'], # Working directory
+ startinfo, # Startup Info
+ procinfo # Process Info
+ )
+ end
+
+ # TODO: Close stdin, stdout and stderr handles in the si_hash unless
+ # they're pointing to one of the standard handles already. [Maybe]
+ if !process_ran
+ raise_last_error("CreateProcess()")
+ end
+
+ # Automatically close the process and thread handles in the
+ # PROCESS_INFORMATION struct unless explicitly told not to.
+ if hash['close_handles']
+ CloseHandle(procinfo[0,4].unpack('L').first)
+ CloseHandle(procinfo[4,4].unpack('L').first)
+ end
+
+ ProcessInfo.new(
+ procinfo[0,4].unpack('L').first, # hProcess
+ procinfo[4,4].unpack('L').first, # hThread
+ procinfo[8,4].unpack('L').first, # hProcessId
+ procinfo[12,4].unpack('L').first # hThreadId
+ )
+ end
+
+ def self.raise_last_error(operation)
+ error_string = "#{operation} failed: #{get_last_error}"
+ last_error_code = GetLastError()
+ if ERROR_CODE_MAP.has_key?(last_error_code)
+ raise ERROR_CODE_MAP[last_error_code], error_string
+ else
+ raise Error, error_string
+ end
+ end
+
+ # List from ruby/win32/win32.c
+ ERROR_CODE_MAP = {
+ ERROR_INVALID_FUNCTION => Errno::EINVAL,
+ ERROR_FILE_NOT_FOUND => Errno::ENOENT,
+ ERROR_PATH_NOT_FOUND => Errno::ENOENT,
+ ERROR_TOO_MANY_OPEN_FILES => Errno::EMFILE,
+ ERROR_ACCESS_DENIED => Errno::EACCES,
+ ERROR_INVALID_HANDLE => Errno::EBADF,
+ ERROR_ARENA_TRASHED => Errno::ENOMEM,
+ ERROR_NOT_ENOUGH_MEMORY => Errno::ENOMEM,
+ ERROR_INVALID_BLOCK => Errno::ENOMEM,
+ ERROR_BAD_ENVIRONMENT => Errno::E2BIG,
+ ERROR_BAD_FORMAT => Errno::ENOEXEC,
+ ERROR_INVALID_ACCESS => Errno::EINVAL,
+ ERROR_INVALID_DATA => Errno::EINVAL,
+ ERROR_INVALID_DRIVE => Errno::ENOENT,
+ ERROR_CURRENT_DIRECTORY => Errno::EACCES,
+ ERROR_NOT_SAME_DEVICE => Errno::EXDEV,
+ ERROR_NO_MORE_FILES => Errno::ENOENT,
+ ERROR_WRITE_PROTECT => Errno::EROFS,
+ ERROR_BAD_UNIT => Errno::ENODEV,
+ ERROR_NOT_READY => Errno::ENXIO,
+ ERROR_BAD_COMMAND => Errno::EACCES,
+ ERROR_CRC => Errno::EACCES,
+ ERROR_BAD_LENGTH => Errno::EACCES,
+ ERROR_SEEK => Errno::EIO,
+ ERROR_NOT_DOS_DISK => Errno::EACCES,
+ ERROR_SECTOR_NOT_FOUND => Errno::EACCES,
+ ERROR_OUT_OF_PAPER => Errno::EACCES,
+ ERROR_WRITE_FAULT => Errno::EIO,
+ ERROR_READ_FAULT => Errno::EIO,
+ ERROR_GEN_FAILURE => Errno::EACCES,
+ ERROR_LOCK_VIOLATION => Errno::EACCES,
+ ERROR_SHARING_VIOLATION => Errno::EACCES,
+ ERROR_WRONG_DISK => Errno::EACCES,
+ ERROR_SHARING_BUFFER_EXCEEDED => Errno::EACCES,
+# ERROR_BAD_NETPATH => Errno::ENOENT,
+# ERROR_NETWORK_ACCESS_DENIED => Errno::EACCES,
+# ERROR_BAD_NET_NAME => Errno::ENOENT,
+ ERROR_FILE_EXISTS => Errno::EEXIST,
+ ERROR_CANNOT_MAKE => Errno::EACCES,
+ ERROR_FAIL_I24 => Errno::EACCES,
+ ERROR_INVALID_PARAMETER => Errno::EINVAL,
+ ERROR_NO_PROC_SLOTS => Errno::EAGAIN,
+ ERROR_DRIVE_LOCKED => Errno::EACCES,
+ ERROR_BROKEN_PIPE => Errno::EPIPE,
+ ERROR_DISK_FULL => Errno::ENOSPC,
+ ERROR_INVALID_TARGET_HANDLE => Errno::EBADF,
+ ERROR_INVALID_HANDLE => Errno::EINVAL,
+ ERROR_WAIT_NO_CHILDREN => Errno::ECHILD,
+ ERROR_CHILD_NOT_COMPLETE => Errno::ECHILD,
+ ERROR_DIRECT_ACCESS_HANDLE => Errno::EBADF,
+ ERROR_NEGATIVE_SEEK => Errno::EINVAL,
+ ERROR_SEEK_ON_DEVICE => Errno::EACCES,
+ ERROR_DIR_NOT_EMPTY => Errno::ENOTEMPTY,
+# ERROR_DIRECTORY => Errno::ENOTDIR,
+ ERROR_NOT_LOCKED => Errno::EACCES,
+ ERROR_BAD_PATHNAME => Errno::ENOENT,
+ ERROR_MAX_THRDS_REACHED => Errno::EAGAIN,
+# ERROR_LOCK_FAILED => Errno::EACCES,
+ ERROR_ALREADY_EXISTS => Errno::EEXIST,
+ ERROR_INVALID_STARTING_CODESEG => Errno::ENOEXEC,
+ ERROR_INVALID_STACKSEG => Errno::ENOEXEC,
+ ERROR_INVALID_MODULETYPE => Errno::ENOEXEC,
+ ERROR_INVALID_EXE_SIGNATURE => Errno::ENOEXEC,
+ ERROR_EXE_MARKED_INVALID => Errno::ENOEXEC,
+ ERROR_BAD_EXE_FORMAT => Errno::ENOEXEC,
+ ERROR_ITERATED_DATA_EXCEEDS_64k => Errno::ENOEXEC,
+ ERROR_INVALID_MINALLOCSIZE => Errno::ENOEXEC,
+ ERROR_DYNLINK_FROM_INVALID_RING => Errno::ENOEXEC,
+ ERROR_IOPL_NOT_ENABLED => Errno::ENOEXEC,
+ ERROR_INVALID_SEGDPL => Errno::ENOEXEC,
+ ERROR_AUTODATASEG_EXCEEDS_64k => Errno::ENOEXEC,
+ ERROR_RING2SEG_MUST_BE_MOVABLE => Errno::ENOEXEC,
+ ERROR_RELOC_CHAIN_XEEDS_SEGLIM => Errno::ENOEXEC,
+ ERROR_INFLOOP_IN_RELOC_CHAIN => Errno::ENOEXEC,
+ ERROR_FILENAME_EXCED_RANGE => Errno::ENOENT,
+ ERROR_NESTING_NOT_ALLOWED => Errno::EAGAIN,
+# ERROR_PIPE_LOCAL => Errno::EPIPE,
+ ERROR_BAD_PIPE => Errno::EPIPE,
+ ERROR_PIPE_BUSY => Errno::EAGAIN,
+ ERROR_NO_DATA => Errno::EPIPE,
+ ERROR_PIPE_NOT_CONNECTED => Errno::EPIPE,
+ ERROR_OPERATION_ABORTED => Errno::EINTR,
+# ERROR_NOT_ENOUGH_QUOTA => Errno::ENOMEM,
+ ERROR_MOD_NOT_FOUND => Errno::ENOENT,
+ WSAEINTR => Errno::EINTR,
+ WSAEBADF => Errno::EBADF,
+# WSAEACCES => Errno::EACCES,
+ WSAEFAULT => Errno::EFAULT,
+ WSAEINVAL => Errno::EINVAL,
+ WSAEMFILE => Errno::EMFILE,
+ WSAEWOULDBLOCK => Errno::EWOULDBLOCK,
+ WSAEINPROGRESS => Errno::EINPROGRESS,
+ WSAEALREADY => Errno::EALREADY,
+ WSAENOTSOCK => Errno::ENOTSOCK,
+ WSAEDESTADDRREQ => Errno::EDESTADDRREQ,
+ WSAEMSGSIZE => Errno::EMSGSIZE,
+ WSAEPROTOTYPE => Errno::EPROTOTYPE,
+ WSAENOPROTOOPT => Errno::ENOPROTOOPT,
+ WSAEPROTONOSUPPORT => Errno::EPROTONOSUPPORT,
+ WSAESOCKTNOSUPPORT => Errno::ESOCKTNOSUPPORT,
+ WSAEOPNOTSUPP => Errno::EOPNOTSUPP,
+ WSAEPFNOSUPPORT => Errno::EPFNOSUPPORT,
+ WSAEAFNOSUPPORT => Errno::EAFNOSUPPORT,
+ WSAEADDRINUSE => Errno::EADDRINUSE,
+ WSAEADDRNOTAVAIL => Errno::EADDRNOTAVAIL,
+ WSAENETDOWN => Errno::ENETDOWN,
+ WSAENETUNREACH => Errno::ENETUNREACH,
+ WSAENETRESET => Errno::ENETRESET,
+ WSAECONNABORTED => Errno::ECONNABORTED,
+ WSAECONNRESET => Errno::ECONNRESET,
+ WSAENOBUFS => Errno::ENOBUFS,
+ WSAEISCONN => Errno::EISCONN,
+ WSAENOTCONN => Errno::ENOTCONN,
+ WSAESHUTDOWN => Errno::ESHUTDOWN,
+ WSAETOOMANYREFS => Errno::ETOOMANYREFS,
+# WSAETIMEDOUT => Errno::ETIMEDOUT,
+ WSAECONNREFUSED => Errno::ECONNREFUSED,
+ WSAELOOP => Errno::ELOOP,
+ WSAENAMETOOLONG => Errno::ENAMETOOLONG,
+ WSAEHOSTDOWN => Errno::EHOSTDOWN,
+ WSAEHOSTUNREACH => Errno::EHOSTUNREACH,
+# WSAEPROCLIM => Errno::EPROCLIM,
+# WSAENOTEMPTY => Errno::ENOTEMPTY,
+ WSAEUSERS => Errno::EUSERS,
+ WSAEDQUOT => Errno::EDQUOT,
+ WSAESTALE => Errno::ESTALE,
+ WSAEREMOTE => Errno::EREMOTE
+ }
+
+ module_function :create
+end
diff --git a/mixlib-shellout.gemspec b/mixlib-shellout.gemspec
new file mode 100644
index 0000000..9ca11fd
--- /dev/null
+++ b/mixlib-shellout.gemspec
@@ -0,0 +1,23 @@
+$:.unshift(File.dirname(__FILE__) + '/lib')
+require 'mixlib/shellout/version'
+
+Gem::Specification.new do |s|
+ s.name = 'mixlib-shellout'
+ s.version = Mixlib::Shellout::VERSION
+ s.platform = Gem::Platform::RUBY
+ s.has_rdoc = true
+ s.extra_rdoc_files = ["README.md", "LICENSE" ]
+ s.summary = "Run external commands on Unix or Windows"
+ s.description = s.summary
+ s.author = "Opscode"
+ s.email = "info@opscode.com"
+ s.homepage = "http://wiki.opscode.com/"
+
+
+ %w(rspec-core rspec-expectations rspec-mocks).each { |gem| s.add_development_dependency gem }
+
+ s.bindir = "bin"
+ s.executables = []
+ s.require_path = 'lib'
+ s.files = %w(LICENSE README.rdoc) + Dir.glob("lib/**/*")
+end
diff --git a/spec/mixlib/shellout/shellout_spec.rb b/spec/mixlib/shellout/shellout_spec.rb
new file mode 100644
index 0000000..7fcfc9c
--- /dev/null
+++ b/spec/mixlib/shellout/shellout_spec.rb
@@ -0,0 +1,466 @@
+require File.expand_path(File.dirname(__FILE__) + '/../spec_helper')
+require 'tmpdir'
+require 'tempfile'
+require 'timeout'
+
+IS_WINDOWS = RUBY_PLATFORM =~ /mswin|mingw32|windows/
+LINE_ENDING = IS_WINDOWS ? "\r\n" : "\n"
+ECHO_LC_ALL = IS_WINDOWS ? "echo %LC_ALL%" : "echo $LC_ALL"
+
+describe Mixlib::ShellOut do
+ before do
+ @shell_cmd = Mixlib::ShellOut.new("apt-get install chef")
+ end
+
+ it "has a command" do
+ @shell_cmd.command.should == "apt-get install chef"
+ end
+
+ it "defaults to not setting a working directory" do
+ @shell_cmd.cwd.should == nil
+ end
+
+ it "has a user to run the command as" do
+ @shell_cmd.user.should be_nil
+ end
+
+ it "sets the user to run the command as" do
+ @shell_cmd.user = 'root'
+ @shell_cmd.user.should == 'root'
+ end
+
+ it "has a group to run the command as" do
+ @shell_cmd.group.should be_nil
+ end
+
+ it "sets the group to run the command as" do
+ @shell_cmd.group = 'wheel'
+ @shell_cmd.group.should == 'wheel'
+ end
+
+ it "has a set of environment variables to set before running the command" do
+ @shell_cmd.environment.should == {"LC_ALL" => "C"}
+ end
+
+ it "has a umask" do
+ @shell_cmd.umask.should be_nil
+ end
+
+ it "sets the umask using an octal integer" do
+ @shell_cmd.umask = 007777
+ @shell_cmd.umask.should == 007777
+ end
+
+ it "sets the umask using a decimal integer" do
+ @shell_cmd.umask = 2925
+ @shell_cmd.umask.should == 005555
+ end
+
+ it "sets the umask using a string representation of an integer" do
+ @shell_cmd.umask = '7777'
+ @shell_cmd.umask.should == 007777
+ end
+
+ it "returns the user-supplied uid when present" do
+ @shell_cmd.user = 0
+ @shell_cmd.uid.should == 0
+ end
+
+ it "computes the uid of the user when a string/symbolic username is given" do
+ @shell_cmd.user = Etc.getlogin
+ @shell_cmd.uid.should == Etc.getpwuid.uid
+ end
+
+ it "returns the user-supplied gid when present" do
+ @shell_cmd.group = 0
+ @shell_cmd.gid.should == 0
+ end
+
+ it "computes the gid of the user when a string/symbolic groupname is given" do
+ a_group = Etc.getgrent
+ @shell_cmd.group = a_group.name
+ @shell_cmd.gid.should == a_group.gid
+ end
+
+ it "has a timeout defaulting to 600 seconds" do
+ Mixlib::ShellOut.new('foo').timeout.should == 600
+ end
+
+ it "sets the read timeout" do
+ @shell_cmd.timeout = 10
+ @shell_cmd.timeout.should == 10
+ end
+
+ it "has a list of valid exit codes which is just 0 by default" do
+ @shell_cmd.valid_exit_codes.should == [0]
+ end
+
+ it "sets the list of valid exit codes" do
+ @shell_cmd.valid_exit_codes = [0,23,42]
+ @shell_cmd.valid_exit_codes.should == [0,23,42]
+ end
+
+ it "defaults to not having a live stream" do
+ @shell_cmd.live_stream.should be_nil
+ end
+
+ it "sets a live stream" do
+ stream = StringIO.new
+ @shell_cmd.live_stream = stream
+ @shell_cmd.live_stream.should == stream
+ end
+
+ context "when initialized with a hash of options" do
+ before do
+ @opts = { :cwd => '/tmp', :user => 'toor', :group => 'wheel', :umask => '2222',
+ :timeout => 5, :environment => {'RUBY_OPTS' => '-w'}, :returns => [0,1,42],
+ :live_stream => StringIO.new}
+ @shell_cmd = Mixlib::ShellOut.new("brew install couchdb", @opts)
+ end
+
+ it "sets the working dir as specified in the options" do
+ @shell_cmd.cwd.should == '/tmp'
+ end
+
+ it "sets the user as specified in the options" do
+ @shell_cmd.user.should == 'toor'
+ end
+
+ it "sets the group as specified in the options" do
+ @shell_cmd.group.should == 'wheel'
+ end
+
+ it "sets the umask as specified in the options" do
+ @shell_cmd.umask.should == 002222
+ end
+
+ it "sets the timout as specified in the options" do
+ @shell_cmd.timeout.should == 5
+ end
+
+ it "merges the environment with the default environment settings" do
+ @shell_cmd.environment.should == {'LC_ALL' => 'C', 'RUBY_OPTS' => '-w'}
+ end
+
+ it "also accepts :env to set the enviroment for brevity's sake" do
+ @shell_cmd = Mixlib::ShellOut.new("brew install couchdb", :env => {'RUBY_OPTS'=>'-w'})
+ @shell_cmd.environment.should == {'LC_ALL' => 'C', 'RUBY_OPTS' => '-w'}
+ end
+
+ it "does not set any environment settings when given :environment => nil" do
+ @shell_cmd = Mixlib::ShellOut.new("brew install couchdb", :environment => nil)
+ @shell_cmd.environment.should == {}
+ end
+
+ it "sets the list of acceptable return values" do
+ @shell_cmd.valid_exit_codes.should == [0,1,42]
+ end
+
+ it "sets the live stream specified in the options" do
+ @shell_cmd.live_stream.should == @opts[:live_stream]
+ end
+
+ it "raises an error when given an invalid option" do
+ klass = Mixlib::ShellOut::InvalidCommandOption
+ msg = "option ':frab' is not a valid option for Mixlib::ShellOut"
+ lambda { Mixlib::ShellOut.new("foo", :frab => :jab) }.should raise_error(klass, msg)
+ end
+
+ it "chdir to the cwd directory if given" do
+ # /bin should exists on all systems, and is not the default cwd
+ if IS_WINDOWS
+ dir = Dir.tmpdir
+ cmd = Mixlib::ShellOut.new('echo %cd%', :cwd => dir)
+ else
+ dir = "/bin"
+ cmd = Mixlib::ShellOut.new('pwd', :cwd => dir)
+ end
+ cmd.run_command
+ File.expand_path(cmd.stdout.chomp).should == File.expand_path(dir)
+ end
+ end
+
+ context "when initialized with an array of command+args and an options hash" do
+ before do
+ @opts = {:cwd => '/tmp', :user => 'nobody'}
+ @shell_cmd = Mixlib::ShellOut.new('ruby', '-e', %q{'puts "hello"'}, @opts)
+ end
+
+ it "sets the command to the array of command and args" do
+ @shell_cmd.command.should == ['ruby', '-e', %q{'puts "hello"'}]
+ end
+
+ it "evaluates the options" do
+ @shell_cmd.cwd.should == '/tmp'
+ @shell_cmd.user.should == 'nobody'
+ end
+ end
+
+ context "when initialized with an array of command+args and no options" do
+ before do
+ @shell_cmd = Mixlib::ShellOut.new('ruby', '-e', %q{'puts "hello"'})
+ end
+
+ it "sets the command to the array of command+args" do
+ @shell_cmd.command.should == ['ruby', '-e', %q{'puts "hello"'}]
+ end
+
+ end
+
+ context "when created with a live stream" do
+ before do
+ @stream = StringIO.new
+ @shell_cmd = Mixlib::ShellOut.new(%q{ruby -e 'puts "hello"'}, :live_stream => @stream)
+ end
+
+ it "copies the subprocess' stdout to the live stream" do
+ @shell_cmd.run_command
+ @stream.string.should == "hello#{LINE_ENDING}"
+ end
+ end
+
+ describe "handling various subprocess behaviors" do
+ it "collects all of STDOUT and STDERR" do
+ twotime = %q{ruby -e 'STDERR.puts :hello; STDOUT.puts :world'}
+ cmd = Mixlib::ShellOut.new(twotime)
+ cmd.run_command
+ cmd.stderr.should == "hello#{LINE_ENDING}"
+ cmd.stdout.should == "world#{LINE_ENDING}"
+ end
+
+ it "collects the exit status of the command" do
+ cmd = Mixlib::ShellOut.new('ruby -e "exit 0"')
+ status = cmd.run_command.status
+ status.exitstatus.should == 0
+ end
+
+ it "does not hang if a process forks but does not close stdout and stderr" do
+ evil_forker="exit if fork; 10.times { sleep 1}"
+ cmd = Mixlib::ShellOut.new("ruby -e '#{evil_forker}'")
+
+ lambda {Timeout.timeout(2) do
+ cmd.run_command
+ end}.should_not raise_error
+ end
+
+ it "times out when a process takes longer than the specified timeout" do
+ cmd = Mixlib::ShellOut.new("ruby -e \"sleep 2\"", :timeout => 0.1)
+ lambda {cmd.run_command}.should raise_error(Mixlib::ShellOut::CommandTimeout)
+ end
+
+ it "reads all of the output when the subprocess produces more than $buffersize of output" do
+ chatty = "ruby -e \"print('X' * 16 * 1024); print('.' * 1024)\""
+ cmd = Mixlib::ShellOut.new(chatty)
+ cmd.run_command
+ cmd.stdout.should match(/X{16384}\.{1024}/)
+ end
+
+ it "returns empty strings from commands that have no output" do
+ cmd = Mixlib::ShellOut.new(%q{ruby -e 'exit 0'})
+ cmd.run_command
+ cmd.stdout.should == ''
+ cmd.stderr.should == ''
+ end
+
+ it "doesn't hang or lose output when a process closes one of stdout/stderr and continues writing to the other" do
+ halfandhalf = %q{ruby -e 'STDOUT.close;sleep 0.5;STDERR.puts :win'}
+ cmd = Mixlib::ShellOut.new(halfandhalf)
+ cmd.run_command
+ cmd.stderr.should == "win#{LINE_ENDING}"
+ end
+
+ it "does not deadlock when the subprocess writes lots of data to both stdout and stderr" do
+ chatty = %q{ruby -e "puts 'f' * 20_000;STDERR.puts 'u' * 20_000; puts 'f' * 20_000;STDERR.puts 'u' * 20_000"}
+ cmd = Mixlib::ShellOut.new(chatty)
+ cmd.run_command
+ cmd.stdout.should == ('f' * 20_000) + "#{LINE_ENDING}" + ('f' * 20_000) + "#{LINE_ENDING}"
+ cmd.stderr.should == ('u' * 20_000) + "#{LINE_ENDING}" + ('u' * 20_000) + "#{LINE_ENDING}"
+ end
+
+ it "does not deadlock when the subprocess writes lots of data to both stdout and stderr (part2)" do
+ chatty = %q{ruby -e "STDERR.puts 'u' * 20_000; puts 'f' * 20_000;STDERR.puts 'u' * 20_000; puts 'f' * 20_000"}
+ cmd = Mixlib::ShellOut.new(chatty)
+ cmd.run_command
+ cmd.stdout.should == ('f' * 20_000) + "#{LINE_ENDING}" + ('f' * 20_000) + "#{LINE_ENDING}"
+ cmd.stderr.should == ('u' * 20_000) + "#{LINE_ENDING}" + ('u' * 20_000) + "#{LINE_ENDING}"
+ end
+
+ it "doesn't hang or lose output when a process writes, pauses, then continues writing" do
+ stop_and_go = %q{ruby -e 'puts "before";sleep 0.5;puts"after"'}
+ cmd = Mixlib::ShellOut.new(stop_and_go)
+ cmd.run_command
+ cmd.stdout.should == "before#{LINE_ENDING}after#{LINE_ENDING}"
+ end
+
+ it "doesn't hang or lose output when a process pauses before writing" do
+ late_arrival = %q{ruby -e 'sleep 0.5;puts "missed_the_bus"'}
+ cmd = Mixlib::ShellOut.new(late_arrival)
+ cmd.run_command
+ cmd.stdout.should == "missed_the_bus#{LINE_ENDING}"
+ end
+
+ it "uses the C locale by default" do
+ cmd = Mixlib::ShellOut.new(ECHO_LC_ALL)
+ cmd.run_command
+ cmd.stdout.strip.should == 'C'
+ end
+
+ it "does not set any locale when the user gives LC_ALL => nil" do
+ # kinda janky
+ cmd = Mixlib::ShellOut.new(ECHO_LC_ALL, :environment => {"LC_ALL" => nil})
+ cmd.run_command
+ if !ENV['LC_ALL'] && IS_WINDOWS
+ expected = "%LC_ALL%"
+ else
+ expected = ENV['LC_ALL'].to_s.strip
+ end
+ cmd.stdout.strip.should == expected
+ end
+
+ it "uses the requested locale" do
+ cmd = Mixlib::ShellOut.new(ECHO_LC_ALL, :environment => {"LC_ALL" => 'es'})
+ cmd.run_command
+ cmd.stdout.strip.should == 'es'
+ end
+
+ it "recovers the error message when exec fails" do
+ cmd = Mixlib::ShellOut.new("fuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuuu")
+ lambda {cmd.run_command}.should raise_error(Errno::ENOENT)
+ end
+
+ it "closes stdin on the child process so it knows not to wait for any input" do
+ cmd = Mixlib::ShellOut.new(%q{ruby -e 'print STDIN.eof?.to_s'})
+ cmd.run_command
+ cmd.stdout.should == "true"
+ end
+ end
+
+ it "formats itself for exception messages" do
+ cmd = Mixlib::ShellOut.new %q{ruby -e 'STDERR.puts "msg_in_stderr"; puts "msg_in_stdout"'}
+ cmd.run_command
+ cmd.format_for_exception.split("\n")[0].should == %q{---- Begin output of ruby -e 'STDERR.puts "msg_in_stderr"; puts "msg_in_stdout"' ----}
+ cmd.format_for_exception.split("\n")[1].should == %q{STDOUT: msg_in_stdout}
+ cmd.format_for_exception.split("\n")[2].should == %q{STDERR: msg_in_stderr}
+ cmd.format_for_exception.split("\n")[3].should == %q{---- End output of ruby -e 'STDERR.puts "msg_in_stderr"; puts "msg_in_stdout"' ----}
+ end
+
+ describe "running different types of command" do
+ it "runs commands with spaces in the path" do
+ Dir.mktmpdir do |dir|
+ file = File.open("#{dir}/blah blah.cmd", "w")
+ file.write(IS_WINDOWS ? "@echo blah" : "echo blah")
+ file.close
+ File.chmod(0755, file.path)
+
+ cmd = Mixlib::ShellOut.new("\"#{file.path}\"")
+ cmd.run_command
+ cmd.stdout.chomp.should == "blah"
+ end
+ end
+
+ it "runs commands with lots of long arguments" do
+ # This number was chosen because it seems to be an actual maximum
+ # in Windows--somewhere around 6-7K of command line
+ echotext = 10000.upto(11340).map { |x| x.to_s }.join(' ')
+ cmd = Mixlib::ShellOut.new("echo #{echotext}")
+ cmd.run_command
+ cmd.stdout.chomp.should == echotext
+ end
+
+ it "runs commands with quotes and special characters in quotes" do
+ cmd = Mixlib::ShellOut.new(%q{ruby -e 'print "<>&|&&||;"'})
+ cmd.run_command
+ cmd.stdout.should == "<>&|&&||;"
+ end
+
+ it "runs commands with backslashes in them" do
+ cmd = Mixlib::ShellOut.new(%q{ruby -e 'print "\\"\\\\"'})
+ cmd.run_command
+ cmd.stdout.should == "\"\\"
+ end
+
+ it "runs commands with stdout pipes" do
+ Dir.mktmpdir do |dir|
+ cmd = Mixlib::ShellOut.new("ruby -e 'STDOUT.sync = true; STDERR.sync = true; print true; STDERR.print false' | ruby -e 'print STDIN.read.length'")
+ cmd.run_command
+ cmd.stdout.should == "4"
+ cmd.stderr.should == "false"
+ end
+ end
+
+ it "runs commands with stdout file pipes" do
+ Dir.mktmpdir do |dir|
+ cmd = Mixlib::ShellOut.new("ruby -e 'STDOUT.sync = true; STDERR.sync = true; print true; STDERR.print false' > #{dir}/blah.txt")
+ cmd.run_command
+ cmd.stdout.should == ""
+ cmd.stderr.should == "false"
+ IO.read("#{dir}/blah.txt").should == "true"
+ end
+ end
+
+ it "runs commands with stdout and stderr file pipes" do
+ Dir.mktmpdir do |dir|
+ cmd = Mixlib::ShellOut.new("ruby -e 'STDOUT.sync = true; STDERR.sync = true; print true; STDERR.print false' > #{dir}/blah.txt 2>&1")
+ cmd.run_command
+ cmd.stdout.should == ""
+ IO.read("#{dir}/blah.txt").should == "truefalse"
+ end
+ end
+
+ it "runs commands with &&" , :hi => true do
+ cmd = Mixlib::ShellOut.new(%q{ruby -e 'print "foo"' && ruby -e 'print "bar"'})
+ cmd.run_command
+ cmd.stdout.should == "foobar"
+ end
+
+ it "runs commands with ||" do
+ cmd = Mixlib::ShellOut.new(%q{ruby -e 'print "foo"; exit 1' || ruby -e 'print "bar"'})
+ cmd.run_command
+ cmd.status.exitstatus.should == 0
+ cmd.stdout.should == "foobar"
+ end
+ end
+
+ describe "handling process exit codes" do
+ it "raises a InvalidCommandResult error if the exitstatus is nonzero" do
+ cmd = Mixlib::ShellOut.new('ruby -e "exit 2"')
+ cmd.run_command
+ lambda {cmd.error!}.should raise_error(Mixlib::ShellOut::ShellCommandFailed)
+ end
+
+ it "does not raise an error if the command returns a value in the list of valid_exit_codes" do
+ cmd = Mixlib::ShellOut.new('ruby -e "exit 42"', :returns => 42)
+ cmd.run_command
+ lambda {cmd.error!}.should_not raise_error
+ end
+
+ it "raises an error if the command does not return a value in the list of valid_exit_codes" do
+ cmd = Mixlib::ShellOut.new('ruby -e "exit 2"', :returns => [ 0, 1, 42 ])
+ cmd.run_command
+ lambda {cmd.error!}.should raise_error(Mixlib::ShellOut::ShellCommandFailed)
+ end
+
+ it "raises an error if the command returns 0 and the list of valid_exit_codes does not contain 0" do
+ cmd = Mixlib::ShellOut.new('ruby -e "exit 0"', :returns => 42)
+ cmd.run_command
+ lambda {cmd.error!}.should raise_error(Mixlib::ShellOut::ShellCommandFailed)
+ end
+
+ it "includes output with exceptions from #error!" do
+ cmd = Mixlib::ShellOut.new('ruby -e "exit 2"')
+ cmd.run_command
+ begin
+ cmd.error!
+ rescue Mixlib::ShellOut::ShellCommandFailed => e
+ e.message.should match(Regexp.escape(cmd.format_for_exception))
+ end
+ end
+
+ it "errors out when told the result is invalid" do
+ cmd = Mixlib::ShellOut.new('ruby -e "exit 0"')
+ cmd.run_command
+ lambda { cmd.invalid!("I expected this to exit 42, not 0") }.should raise_error(Mixlib::ShellOut::ShellCommandFailed)
+ end
+ end
+
+end
diff --git a/spec/mixlib/spec_helper.rb b/spec/mixlib/spec_helper.rb
new file mode 100644
index 0000000..27d9d6e
--- /dev/null
+++ b/spec/mixlib/spec_helper.rb
@@ -0,0 +1,2 @@
+$:.unshift File.expand_path("../../../lib", __FILE__)
+require 'mixlib/shellout'