class Pwsh::Manager
Create an instance of a PowerShell host and manage execution of PowerShell code inside that host.
Attributes
Public Class Methods
Returns a set of default options for instantiating a manager
@return [Hash] the default options for a new manager
# File lib/pwsh.rb, line 34 def self.default_options { debug: false, pipe_timeout: 30 } end
Return an instance of the manager if one already exists for the specified options or instantiate a new one if needed
@param cmd [String] the full path to the PowerShell executable to manage @param args [Array] the list of additional arguments to pass PowerShell @param options [Hash] the set of options to set the behavior of the manager, including debug/timeout @return [] specific instance matching the specified parameters either newly created or previously instantiated
# File lib/pwsh.rb, line 48 def self.instance(cmd, args, options = {}) options = default_options.merge!(options) key = instance_key(cmd, args, options) manager = @@instances[key] if manager.nil? || !manager.alive? # ignore any errors trying to tear down this unusable instance begin manager.exit unless manager.nil? # rubocop:disable Style/SafeNavigation rescue nil end @@instances[key] = Manager.new(cmd, args, options) end @@instances[key] end
The unique key for a given manager as determined by the full path to the executable, the arguments to pass to the executable, and the options specified for the manager; this enables the code to reuse an existing manager if the same path, arguments, and options are specified.
@return Unique string representing the manager instance.
# File lib/pwsh.rb, line 408 def self.instance_key(cmd, args, options) cmd + args.join(' ') + options[:debug].to_s end
Instantiate a new instance of the PowerShell Manager
@param cmd [String] @param args [Array] @param options [Hash] @return nil
# File lib/pwsh.rb, line 106 def initialize(cmd, args = [], options = {}) @usable = true @powershell_command = cmd @powershell_arguments = args raise "Bad configuration for ENV['lib']=#{ENV['lib']} - invalid path" if Pwsh::Util.invalid_directories?(ENV['lib']) if Pwsh::Util.on_windows? # Named pipes under Windows will automatically be mounted in \\.\pipe\... # https://github.com/dotnet/corefx/blob/a10890f4ffe0fadf090c922578ba0e606ebdd16c/src/System.IO.Pipes/src/System/IO/Pipes/NamedPipeServerStream.Windows.cs#L34 named_pipe_name = "#{SecureRandom.uuid}PsHost" # This named pipe path is Windows specific. pipe_path = "\\\\.\\pipe\\#{named_pipe_name}" else require 'tmpdir' # .Net implements named pipes under Linux etc. as Unix Sockets in the filesystem # Paths that are rooted are not munged within C# Core. # https://github.com/dotnet/corefx/blob/94e9d02ad70b2224d012ac4a66eaa1f913ae4f29/src/System.IO.Pipes/src/System/IO/Pipes/PipeStream.Unix.cs#L49-L60 # https://github.com/dotnet/corefx/blob/a10890f4ffe0fadf090c922578ba0e606ebdd16c/src/System.IO.Pipes/src/System/IO/Pipes/NamedPipeServerStream.Unix.cs#L44 # https://github.com/dotnet/corefx/blob/a10890f4ffe0fadf090c922578ba0e606ebdd16c/src/System.IO.Pipes/src/System/IO/Pipes/NamedPipeServerStream.Unix.cs#L298-L299 named_pipe_name = File.join(Dir.tmpdir, "#{SecureRandom.uuid}PsHost") pipe_path = named_pipe_name end pipe_timeout = options[:pipe_timeout] || self.class.default_options[:pipe_timeout] debug = options[:debug] || self.class.default_options[:debug] native_cmd = Pwsh::Util.on_windows? ? "\"#{cmd}\"" : cmd ps_args = args + ['-File', self.class.template_path, "\"#{named_pipe_name}\""] ps_args << '"-EmitDebugOutput"' if debug # @stderr should never be written to as PowerShell host redirects output stdin, @stdout, @stderr, @ps_process = Open3.popen3("#{native_cmd} #{ps_args.join(' ')}") stdin.close # TODO: Log a debug for "#{Time.now} #{cmd} is running as pid: #{@ps_process[:pid]}" # Wait up to 180 seconds in 0.2 second intervals to be able to open the pipe. # If the pipe_timeout is ever specified as less than the sleep interval it will # never try to connect to a pipe and error out as if a timeout occurred. sleep_interval = 0.2 (pipe_timeout / sleep_interval).to_int.times do begin # rubocop:disable Style/RedundantBegin @pipe = if Pwsh::Util.on_windows? # Pipe is opened in binary mode and must always <- always what?? File.open(pipe_path, 'r+b') else UNIXSocket.new(pipe_path) end break rescue sleep sleep_interval end end if @pipe.nil? # Tear down and kill the process if unable to connect to the pipe; failure to do so # results in zombie processes being left after a caller run. We discovered that # closing @ps_process via .kill instead of using this method actually kills the # watcher and leaves an orphaned process behind. Failing to close stdout and stderr # also leaves clutter behind, so explicitly close those too. @stdout.close unless @stdout.closed? @stderr.close unless @stderr.closed? Process.kill('KILL', @ps_process[:pid]) if @ps_process.alive? raise "Failure waiting for PowerShell process #{@ps_process[:pid]} to start pipe server" end # TODO: Log a debug for "#{Time.now} PowerShell initialization complete for pid: #{@ps_process[:pid]}" at_exit { exit } end
Default arguments for running Windows PowerShell via the manager
@return [Array] array of command flags to pass Windows PowerShell
# File lib/pwsh.rb, line 328 def self.powershell_args ps_args = ['-NoProfile', '-NonInteractive', '-NoLogo', '-ExecutionPolicy', 'Bypass'] ps_args << '-Command' unless windows_powershell_supported? ps_args end
The path to Windows PowerShell on the system
@return [String] the absolute path to the PowerShell executable. Returns 'powershell.exe' if no more specific path found.
# File lib/pwsh.rb, line 338 def self.powershell_path if File.exist?("#{ENV['SYSTEMROOT']}\\sysnative\\WindowsPowershell\\v1.0\\powershell.exe") "#{ENV['SYSTEMROOT']}\\sysnative\\WindowsPowershell\\v1.0\\powershell.exe" elsif File.exist?("#{ENV['SYSTEMROOT']}\\system32\\WindowsPowershell\\v1.0\\powershell.exe") "#{ENV['SYSTEMROOT']}\\system32\\WindowsPowershell\\v1.0\\powershell.exe" else 'powershell.exe' end end
Takes a given input byte-stream from PowerShell, length-prefixed, and reads the key-value pairs from that output until all the information is retrieved. Mutates the given bytes.
@return [Hash] String pairs representing the information passed
# File lib/pwsh.rb, line 459 def self.ps_output_to_hash!(bytes) hash = {} hash[read_length_prefixed_string!(bytes).to_sym] = read_length_prefixed_string!(bytes) until bytes.empty? hash end
Default arguments for running PowerShell 6+ via the manager
@return [Array] array of command flags to pass PowerShell 6+
# File lib/pwsh.rb, line 398 def self.pwsh_args ['-NoProfile', '-NonInteractive', '-NoLogo', '-ExecutionPolicy', 'Bypass'] end
Retrieves the absolute path to pwsh
@return [String] the absolute path to the found pwsh executable. Returns nil when it does not exist
# File lib/pwsh.rb, line 351 def self.pwsh_path(additional_paths = []) # Environment variables on Windows are not case sensitive however ruby hash keys are. # Convert all the key names to upcase so we can be sure to find PATH etc. # Also while ruby can have difficulty changing the case of some UTF8 characters, we're # only going to use plain ASCII names so this is safe. current_path = Pwsh::Util.on_windows? ? ENV.select { |k, _| k.upcase == 'PATH' }.values[0] : ENV['PATH'] current_path = '' if current_path.nil? # Prefer any additional paths # TODO: Should we just use arrays by now instead of appending strings? search_paths = additional_paths.empty? ? current_path : additional_paths.join(File::PATH_SEPARATOR) + File::PATH_SEPARATOR + current_path # If we're on Windows, try the default installation locations as a last resort. # https://docs.microsoft.com/en-us/powershell/scripting/install/installing-powershell-core-on-windows?view=powershell-6#msi # https://docs.microsoft.com/en-us/powershell/scripting/install/installing-powershell-core-on-windows?view=powershell-7.1 if Pwsh::Util.on_windows? # TODO: What about PS 8? # TODO: Need to check on French/Turkish windows if ENV['PROGRAMFILES'] parses UTF8 names correctly # TODO: Need to ensure ENV['PROGRAMFILES'] is case insensitive, i.e. ENV['PROGRAMFiles'] should also resolve on Windows search_paths += ";#{ENV['PROGRAMFILES']}\\PowerShell\\6" \ ";#{ENV['PROGRAMFILES(X86)']}\\PowerShell\\6" \ ";#{ENV['PROGRAMFILES']}\\PowerShell\\7" \ ";#{ENV['PROGRAMFILES(X86)']}\\PowerShell\\7" end raise 'No paths discovered to search for Powershell!' if search_paths.split(File::PATH_SEPARATOR).empty? pwsh_paths = [] # TODO: THis could probably be done better, but it works! if Pwsh::Util.on_windows? search_paths.split(File::PATH_SEPARATOR).each do |path| pwsh_paths << File.join(path, 'pwsh.exe') if File.exist?(File.join(path, 'pwsh.exe')) end else search_paths.split(File::PATH_SEPARATOR).each do |path| pwsh_paths << File.join(path, 'pwsh') if File.exist?(File.join(path, 'pwsh')) end end # TODO: not sure about nil? but .empty? is MethodNotFound on nil raise 'No pwsh discovered!' if pwsh_paths.nil? || pwsh_paths.empty? pwsh_paths[0] end
Determine whether or not the manager is supported on the machine for PowerShell 6+
@return [Bool] true if pwsh is manageable
# File lib/pwsh.rb, line 96 def self.pwsh_supported? !win32console_enabled? end
The manager sends a 4-byte integer representing the number of bytes to read for the incoming string. This method reads that prefix and then reads the specified number of bytes. Mutates the given bytes, removing the length prefixed value.
@return [String] The UTF-8 encoded string containing the payload
# File lib/pwsh.rb, line 446 def self.read_length_prefixed_string!(bytes) # 32 bit integer in Little Endian format length = bytes.slice!(0, 4).unpack1('V') return nil if length.zero? bytes.slice!(0, length).force_encoding(Encoding::UTF_8) end
Return whether or not a particular stream is valid and readable
@return [Bool] true if stream is readable and open
# File lib/pwsh.rb, line 415 def self.readable?(stream, timeout = 0.5) raise Errno::EPIPE unless stream_valid?(stream) read_ready = IO.select([stream], [], [], timeout) read_ready && stream == read_ready[0][0] && !stream.eof? end
When a stream has been closed by handle, but Ruby still has a file descriptor for it, it can be tricky to detemine that it's actually dead. The .fileno will still return an int, and calling get_osfhandle against it returns what the CRT thinks is a valid Windows HANDLE value, but that may no longer exist.
@return [Bool] true if stream is open and operational
# File lib/pwsh.rb, line 429 def self.stream_valid?(stream) # When a stream is closed, it's obviously invalid, but Ruby doesn't always know !stream.closed? && # So calling stat will yield and EBADF when underlying OS handle is bad # as this resolves to a HANDLE and then calls the Windows API !stream.stat.nil? # Any exceptions mean the stream is dead rescue false end
Return the path to the bootstrap template
@return [String] full path to the bootstrap template
# File lib/pwsh.rb, line 246 def self.template_path # A PowerShell -File compatible path to bootstrap the instance path = File.expand_path('../templates', __FILE__) path = File.join(path, 'init.ps1').gsub('/', '\\') "\"#{path}\"" end
Determine whether or not the Win32 Console is enabled
@return [Bool] true if enabled
# File lib/pwsh.rb, line 70 def self.win32console_enabled? @win32console_enabled ||= defined?(Win32) && defined?(Win32::Console) && Win32::Console.instance_of?(Class) end
Determine whether or not the manager is supported on the machine for Windows PowerShell
@return [Bool] true if Windows PowerShell is manageable
# File lib/pwsh.rb, line 87 def self.windows_powershell_supported? Pwsh::Util.on_windows? && Pwsh::WindowsPowerShell.compatible_version? && !win32console_enabled? end
Public Instance Methods
Return whether or not the manager is running, usable, and the I/O streams remain open.
@return [Bool] true if manager is in working state
# File lib/pwsh.rb, line 178 def alive? # powershell process running @ps_process.alive? && # explicitly set during a read / write failure, like broken pipe EPIPE @usable && # an explicit failure state might not have been hit, but IO may be closed self.class.stream_valid?(@pipe) && self.class.stream_valid?(@stdout) && self.class.stream_valid?(@stderr) end
Read from a specified pipe for as long as the signal is locked and the pipe is readable. Then return the data as an array of UTF-8 strings.
@param pipe [IO] the I/O pipe to read @param signal [Mutex] the signal to wait for whilst reading data @return [Array] An empty array if no data read or an array wrapping a single UTF-8 string if output received.
# File lib/pwsh.rb, line 534 def drain_pipe_until_signaled(pipe, signal) output = [] read_from_pipe(pipe) { |s| output << s } while signal.locked? # There's ultimately a bit of a race here # Read one more time after signal is received read_from_pipe(pipe, 0) { |s| output << s } while self.class.readable?(pipe) # String has been binary up to this point, so force UTF-8 now output == [] ? [] : [output.join('').force_encoding(Encoding::UTF_8)] end
Executes PowerShell code over the PowerShell manager and returns the results.
@param powershell_code [String] The PowerShell code to execute via the manager @return [Array] Array of three strings representing the output, native stdout, and stderr
# File lib/pwsh.rb, line 609 def exec_read_result(powershell_code) write_pipe(pipe_command(:execute)) write_pipe(length_prefixed_string(powershell_code)) read_streams # If any pipes are broken, the manager is totally hosed # Bad file descriptors mean closed stream handles # EOFError is a closed pipe (could be as a result of tearing down process) # Errno::ECONNRESET is a closed unix domain socket (could be as a result of tearing down process) rescue Errno::EPIPE, Errno::EBADF, EOFError, Errno::ECONNRESET => e @usable = false [nil, nil, [e.inspect, e.backtrace].flatten] # Catch closed stream errors specifically rescue IOError => e raise unless e.message.start_with?('closed stream') @usable = false [nil, nil, [e.inspect, e.backtrace].flatten] end
Run specified powershell code via the manager
@param powershell_code [String] @param timeout_ms [Int] @param working_dir [String] @param environment_variables [Hash] @return [Hash] Hash containing exitcode, stderr, native_stdout and stdout
# File lib/pwsh.rb, line 196 def execute(powershell_code, timeout_ms = nil, working_dir = nil, environment_variables = []) code = make_ps_code(powershell_code, timeout_ms, working_dir, environment_variables) # err is drained stderr pipe (not captured by redirection inside PS) # or during a failure, a Ruby callstack array out, native_stdout, err = exec_read_result(code) # an error was caught during execution that has invalidated any results return { exitcode: -1, stderr: err } if out.nil? && !@usable out[:exitcode] = out[:exitcode].to_i unless out[:exitcode].nil? # If err contains data it must be "real" stderr output # which should be appended to what PS has already captured out[:stderr] = out[:stderr].nil? ? [] : [out[:stderr]] out[:stderr] += err unless err.nil? out[:native_stdout] = native_stdout out end
Tear down the instance of the manager, shutting down the pipe and process.
@return nil
# File lib/pwsh.rb, line 218 def exit @usable = false # TODO: Log a debug for "Pwsh exiting..." # Ask PowerShell pipe server to shutdown if its still running # rather than expecting the pipe.close to terminate it begin write_pipe(pipe_command(:exit)) unless @pipe.closed? rescue nil end # Pipe may still be open, but if stdout / stderr are deat the PS # process is in trouble and will block forever on a write to the # pipe. It's safer to close pipe on the Ruby side, which gracefully # shuts down the PS side. @pipe.close unless @pipe.closed? @stdout.close unless @stdout.closed? @stderr.close unless @stderr.closed? # Wait up to 2 seconds for the watcher thread to full exit @ps_process.join(2) end
Take a given string and prefix it with a 4-byte length and encode for sending to the PowerShell manager. Data format is:
4 bytes - Little Endian encoded 32-bit integer length of string Intel CPUs are little endian, hence the .NET Framework typically is
variable length - UTF8 encoded string bytes
@return A binary encoded string prefixed with a 4-byte length identifier
# File lib/pwsh.rb, line 490 def length_prefixed_string(data) msg = data.encode(Encoding::UTF_8) # https://ruby-doc.org/core-1.9.3/Array.html#method-i-pack [msg.bytes.length].pack('V') + msg.force_encoding(Encoding::BINARY) end
Return the block of code to be run by the manager with appropriate settings
@param powershell_code [String] the actual PowerShell code you want to run @param timeout_ms [Int] the number of milliseconds to wait for the command to run @param working_dir [String] the working directory for PowerShell to execute from within @param environment_variables [Array] Any overrides for environment variables you want to specify @return [String] PowerShell code to be executed via the manager with appropriate params per config.
# File lib/pwsh.rb, line 260 def make_ps_code(powershell_code, timeout_ms = nil, working_dir = nil, environment_variables = []) begin # Zero timeout is a special case. Other modules sometimes treat this # as an infinite timeout. We don't support infinite, so for the case # of a user specifying zero, we sub in the default value of 300s. timeout_ms = 300 * 1000 if timeout_ms.zero? timeout_ms = Integer(timeout_ms) # Lower bound protection. The polling resolution is only 50ms. timeout_ms = 50 if timeout_ms < 50 rescue timeout_ms = 300 * 1000 end # Environment array firstly needs to be parsed and converted into a hashtable. # And then the values passed in need to be converted to a PowerShell Hashtable. # # Environment parsing is based on the puppet exec equivalent code # https://github.com/puppetlabs/puppet/blob/a9f77d71e992fc2580de7705847e31264e0fbebe/lib/puppet/provider/exec.rb#L35-L49 environment = {} if (envlist = environment_variables) envlist = [envlist] unless envlist.is_a? Array envlist.each do |setting| if setting =~ /^(\w+)=((.|\n)+)$/ env_name = Regexp.last_match(1) value = Regexp.last_match(2) if environment.include?(env_name) || environment.include?(env_name.to_sym) # TODO: log a warning for "Overriding environment setting '#{env_name}' with '#{value}'" end environment[env_name] = value else # rubocop:disable Style/EmptyElse # TODO: log a warning for "Cannot understand environment setting #{setting.inspect}" end end end # Convert the Ruby Hashtable into PowerShell syntax additional_environment_variables = '@{' unless environment.empty? environment.each do |name, value| # PowerShell escapes single quotes inside a single quoted string by just adding # another single quote i.e. a value of foo'bar turns into 'foo''bar' when single quoted. ps_name = name.gsub('\'', '\'\'') ps_value = value.gsub('\'', '\'\'') additional_environment_variables += " '#{ps_name}' = '#{ps_value}';" end end additional_environment_variables += '}' # PS Side expects Invoke-PowerShellUserCode is always the return value here # TODO: Refactor to use <<~ as soon as we can :sob: <<-CODE $params = @{ Code = @' #{powershell_code} '@ TimeoutMilliseconds = #{timeout_ms} WorkingDirectory = "#{working_dir}" AdditionalEnvironmentVariables = #{additional_environment_variables} } Invoke-PowerShellUserCode @params CODE end
This is the command that the ruby process will send to the PowerShell process and utilizes a 1 byte command identifier
0 - Exit 1 - Execute
@return Single byte representing the specified command
# File lib/pwsh.rb, line 473 def pipe_command(command) case command when :exit "\x00" when :execute "\x01" end end
Read output from the PowerShell manager process via the pipe.
@param pipe [IO] I/O Pipe to read from @param timeout [Float] The number of seconds to wait for the pipe to be readable @yield [String] a binary encoded string chunk @return nil
# File lib/pwsh.rb, line 517 def read_from_pipe(pipe, timeout = 0.1, &_block) if self.class.readable?(pipe, timeout) l = pipe.readpartial(4096) # TODO: Log a debug for "#{Time.now} PIPE> #{l}" # Since readpartial may return a nil at EOF, skip returning that value yield l unless l.nil? end nil end
Open threads and pipes to read stdout and stderr from the PowerShell manager, then continue to read data from the manager until either all data is returned or an error interrupts the normal flow, then return that data.
@return [Array] Array of three strings representing the output, native stdout, and stderr
# File lib/pwsh.rb, line 552 def read_streams pipe_done_reading = Mutex.new pipe_done_reading.lock # TODO: Uncomment again when implementing logging # start_time = Time.now stdout_reader = Thread.new { drain_pipe_until_signaled(@stdout, pipe_done_reading) } stderr_reader = Thread.new { drain_pipe_until_signaled(@stderr, pipe_done_reading) } pipe_reader = Thread.new(@pipe) do |pipe| # Read a Little Endian 32-bit integer for length of response expected_response_length = pipe.sysread(4).unpack1('V') next nil if expected_response_length.zero? # Reads the expected bytes as a binary string or fails buffer = '' # sysread may not return all of the requested bytes due to buffering or the # underlying IO system. Keep reading from the pipe until all the bytes are read. loop do buffer.concat(pipe.sysread(expected_response_length - buffer.length)) break if buffer.length >= expected_response_length end buffer end # TODO: Log a debug for "Waited #{Time.now - start_time} total seconds." # Block until sysread has completed or errors begin output = pipe_reader.value output = self.class.ps_output_to_hash!(output) unless output.nil? ensure # Signal stdout / stderr readers via Mutex so that # Ruby doesn't crash waiting on an invalid event. pipe_done_reading.unlock end # Given redirection on PowerShell side, this should always be empty stdout = stdout_reader.value [ output, stdout == [] ? nil : stdout.join(''), # native stdout stderr_reader.value # native stderr ] ensure # Failsafe if the prior unlock was never reached / Mutex wasn't unlocked pipe_done_reading.unlock if pipe_done_reading.locked? # Wait for all non-nil threads to see mutex unlocked and finish [pipe_reader, stdout_reader, stderr_reader].compact.each(&:join) end
Writes binary-encoded data to the PowerShell manager process via the pipe.
@return nil
# File lib/pwsh.rb, line 499 def write_pipe(input) # For Compat with Ruby 2.1 and lower, it's important to use syswrite and # not write - otherwise, the pipe breaks after writing 1024 bytes. written = @pipe.syswrite(input) @pipe.flush if written != input.length # rubocop:disable Style/GuardClause msg = "Only wrote #{written} out of #{input.length} expected bytes to PowerShell pipe" raise Errno::EPIPE.new, msg end end