ruby-pwsh 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 45a4e8fcc1b0bf5eba64fc3798038898f082e1f2306523e197de6c801f61c872
4
+ data.tar.gz: 31817197b1a07056dd1f4e6d9296eec1ceb4a39380244a9d00aa937a4ac76aa6
5
+ SHA512:
6
+ metadata.gz: 5fd66bd52888d7a52bc262d2f977f6100b723531ee283aff31aec7e95ab9b0605e3fce7dcb94e34d13de4b01d0236a56b49ca60e55148e7df4a7dfee63cd8f2d
7
+ data.tar.gz: 98668a759b733ddc17651011709c1070ae0b407afb84f4de356ec2b181d8353db0d5b57f1cdc64cf67039c994fb6811c679140a3177ba9e489ba72a3874032d0
@@ -0,0 +1,2 @@
1
+ # Ensure line endings are always LF
2
+ * text eol=lf
@@ -0,0 +1,15 @@
1
+ /.bundle/
2
+ /.yardoc
3
+ /_yardoc/
4
+ /coverage/
5
+ /doc/
6
+ /pkg/
7
+ /spec/reports/
8
+ /tmp/
9
+ /bin/
10
+
11
+ # rspec failure tracking
12
+ .rspec_status
13
+
14
+ Gemfile.local
15
+ Gemfile.lock
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
@@ -0,0 +1,31 @@
1
+ Layout/EndOfLine:
2
+ Description: Don't enforce CRLF on Windows.
3
+ Enabled: false
4
+ Metrics/LineLength:
5
+ Description: People have wide screens, use them.
6
+ Max: 200
7
+ Metrics/BlockLength:
8
+ Enabled: false
9
+ Metrics/MethodLength:
10
+ Enabled: false
11
+ Metrics/ClassLength:
12
+ Enabled: false
13
+ Metrics/PerceivedComplexity:
14
+ Enabled: false
15
+ Metrics/CyclomaticComplexity:
16
+ Enabled: false
17
+ Metrics/AbcSize:
18
+ Enabled: false
19
+
20
+ # requires 2.3's squiggly HEREDOC support, which we can't use, yet
21
+ # see http://www.virtuouscode.com/2016/01/06/about-the-ruby-squiggly-heredoc-syntax/
22
+ Layout/IndentHeredoc:
23
+ Enabled: false
24
+ # Need to ignore rubocop complaining about the name of the library.
25
+ Naming/FileName:
26
+ Exclude:
27
+ - lib/ruby-pwsh.rb
28
+ Style/RescueStandardError:
29
+ Enabled: false
30
+ Style/ExpandPathArguments:
31
+ Enabled: false
@@ -0,0 +1,25 @@
1
+ ---
2
+ dist: bionic
3
+ language: ruby
4
+ cache: bundler
5
+ sudo: true
6
+ before_install:
7
+ - bundle -v
8
+ - rm -f Gemfile.lock
9
+ - gem update --system $RUBYGEMS_VERSION
10
+ - gem --version
11
+ - bundle -v
12
+ - wget -q https://packages.microsoft.com/config/ubuntu/18.04/packages-microsoft-prod.deb
13
+ - sudo dpkg -i packages-microsoft-prod.deb
14
+ - sudo apt-get update
15
+ - sudo add-apt-repository universe
16
+ - sudo apt-get install -y powershell
17
+ - pwsh -v
18
+ script:
19
+ - 'bundle exec rake $CHECK'
20
+ rvm:
21
+ - 2.5.1
22
+ matrix:
23
+ include:
24
+ - env: CHECK="rubocop"
25
+ - env: CHECK='spec'
@@ -0,0 +1,2 @@
1
+ # Setting ownership to the modules team
2
+ * @puppetlabs/modules
@@ -0,0 +1,70 @@
1
+ # PowerShell Manager: Design and Architecture
2
+
3
+ This gem allows the use of a long-lived manager to which Ruby can send PowerShell invocations and receive the exection output.
4
+ This reduces the overhead time to execute PowerShell commands from seconds to milliseconds because each execution does not need to spin up a PowerShell process, execute a single pipeline, and tear the process down.
5
+
6
+ The manager operates by instantiating a custom PowerShell host process to which Ruby can then send commands over an IO pipe—
7
+ on Windows machines, named pipes, on Unix/Linux, Unix Domain Sockets.
8
+
9
+ ## Communication Between Ruby and PowerShell Host Process
10
+
11
+ Communication between Ruby and the PowerShell host process uses binary encoded strings with a [4-byte prefix indicating how long the message is](https://en.wikipedia.org/wiki/Type-length-value).
12
+ The length prefix is a Little Endian encoded 32-bit integer.
13
+ The string being passed is always UTF-8.
14
+
15
+ Before a command string is sent to the PowerShell host process, a single 1-byte command identifier is sent—
16
+ `0` to tell the process to exit, `1` to tell the process to execute the next incoming string.
17
+
18
+ The PowerShell code to be executed is always wrapped in the following for execution to standardize behavior inside the PowerShell host process:
19
+
20
+ ```powershell
21
+ $params = @{
22
+ Code = @'
23
+ #{powershell_code}
24
+ '@
25
+ TimeoutMilliseconds = #{timeout_ms}
26
+ WorkingDirectory = "#{working_dir}"
27
+ ExecEnvironmentVariables = #{exec_environment_variables}
28
+ }
29
+
30
+ Invoke-PowerShellUserCode @params
31
+ ```
32
+
33
+ The code itself is placed inside a herestring and the timeout (integer), working directory (string), and environment variables (hash), if any, are passed as well.
34
+
35
+ ![Diagram of communication flow for execution between Ruby and PowerShell manager](./design-comms.png)
36
+
37
+ ### Output
38
+
39
+ The return from a Ruby command will always include:
40
+
41
+ + `stdout` from the output streams, as if using `*>&1` to redirect
42
+ + `exitcode` for the exit code of the execution; this will always be `0`, unless an exit code is specified or an exception is _thrown_.
43
+ + `stderr` will always be an empty array.
44
+ + `errormessage` will be the exception message of any thrown exception during execution.
45
+ + `native_stdout` will always be nil.
46
+
47
+ #### Error Handling
48
+
49
+ Because PowerShell does not halt execution when an error is encountered, only when an terminating exception is thrown, the manager _also_ continues to execute until it encounters a terminating exception when executing commands.
50
+ This means that `Write-Error` messages will go to the stdout log but will not cause a change in the `exitcode` or populate the `errormessage` field.
51
+ Using `Throw` or any other method of generating a terminating exception _will_ set the `exitcode` to `1` and populate the `errormessage` field.
52
+
53
+ ## Multiple PowerShell Host Processes
54
+
55
+ Until told otherwise, or they break, or their parent process closes, the instantiated PowerShell host processes will remain alive and waiting for further commands.
56
+ The significantly speeds up the execution of serialized commands, making continual interoperation between Ruby and PowerShell less complex for the developer leveraging this library.
57
+
58
+ In order to do this, the manager class has a class variable, `@@instances`, which contains a hash of the PowerShell hosts:
59
+
60
+ + The key is the unique combination of options - path to the executable, flags, and additional options - passed to create the instance.
61
+ + The value is the current state of that instance of the PowerShell host process.
62
+
63
+ If you attempt to instantiate an instance of the manager using the `instance` method, it will _first_ look to see if the specified manager and host process are already built and alive - if the manager instance does not exist or the host process is dead, _then_ it will spin up a new host process.
64
+
65
+ In test executions, standup of an instance takes around 1.5 seconds - accessing a pre-existing instance takes thousandths of a second.
66
+
67
+ ## Multithreading
68
+
69
+ The manager and PowerShell host process are designed to be used single-threadedly with the PowerShell host expecting a single command and returning a single output at a time.
70
+ It does not at this time have additional guarding against being sent commands by multiple processes, but since the handles are unique IDs, this should not happen in practice.
data/Gemfile ADDED
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ source 'https://rubygems.org'
4
+
5
+ # Specify your gem's dependencies in pwsh.gemspec
6
+ gemspec
7
+
8
+ group :test do
9
+ gem 'ffi'
10
+ gem 'rake', '>= 10.0'
11
+ gem 'rspec', '~> 3.0'
12
+ gem 'rspec-collection_matchers', '~> 1.0'
13
+ gem 'rspec-its', '~> 1.0'
14
+ gem 'rubocop'
15
+ gem 'rubocop-rspec'
16
+ gem 'simplecov'
17
+ end
18
+
19
+ group :development do
20
+ # TODO: Use gem instead of git. Section mapping is merged into master, but not yet released
21
+ gem 'github_changelog_generator', git: 'https://github.com/skywinder/github-changelog-generator.git', ref: '20ee04ba1234e9e83eb2ffb5056e23d641c7a018'
22
+ gem 'yard'
23
+ end
24
+
25
+ group :pry do
26
+ gem 'fuubar'
27
+
28
+ if RUBY_VERSION == '1.8.7'
29
+ gem 'debugger'
30
+ elsif RUBY_VERSION =~ /^2\.[01]/
31
+ gem 'byebug', '~> 9.0.0'
32
+ gem 'pry-byebug'
33
+ elsif RUBY_VERSION =~ /^2\.[23456789]/
34
+ gem 'pry-byebug' # rubocop:disable Bundler/DuplicatedGem
35
+ else
36
+ gem 'pry-debugger'
37
+ end
38
+
39
+ gem 'pry-stack_explorer'
40
+ end
41
+
42
+ # Evaluate Gemfile.local and ~/.gemfile if they exist
43
+ extra_gemfiles = [
44
+ "#{__FILE__}.local",
45
+ File.join(Dir.home, '.gemfile')
46
+ ]
47
+
48
+ extra_gemfiles.each do |gemfile|
49
+ eval(File.read(gemfile), binding) if File.file?(gemfile) && File.readable?(gemfile) # rubocop:disable Security/Eval
50
+ end
51
+ # vim: syntax=ruby
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2019 PuppetLabs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
@@ -0,0 +1,69 @@
1
+ # ruby-pwsh
2
+
3
+ > _The PowerShell gem._
4
+
5
+ This gem enables you to execute PowerShell from within ruby without having to instantiate and tear down a PowerShell process for each command called.
6
+ It supports Windows PowerShell as well as PowerShell Core (and, soon, _just_ PowerShell) - if you're running *PowerShell v3+, this gem supports you.
7
+
8
+ The `Manager` class enables you to execute and interoperate with PowerShell from within ruby, leveraging the strengths of both languages as needed.
9
+
10
+ ## Installation
11
+
12
+ Add this line to your application's Gemfile:
13
+
14
+ ```ruby
15
+ gem 'ruby-pwsh'
16
+ ```
17
+
18
+ And then execute:
19
+
20
+ ```shell
21
+ bundle install
22
+ ```
23
+
24
+ Or install it yourself as:
25
+
26
+ ```shell
27
+ gem install ruby-pwsh
28
+ ```
29
+
30
+ ## Usage
31
+
32
+ Instantiating the manager can be done using some defaults:
33
+
34
+ ```ruby
35
+ # Instantiate the manager for Windows PowerShell, using the default path and arguments
36
+ # Note that this takes a few seconds to instantiate.
37
+ posh = Pwsh::Manager.instance(Pwsh::Manager.powershell_path, Pwsh::Manager.powershell_args)
38
+ # If you try to create another manager with the same arguments it will reuse the existing one.
39
+ ps = Pwsh::Manager.instance(Pwsh::Manager.powershell_path, Pwsh::Manager.powershell_args)
40
+ # Note that this time the return is very fast.
41
+ # We can also use the defaults for PowerShell Core, though these only work if PowerShell is
42
+ # installed to the default paths - if it is installed anywhere else, you'll need to specify
43
+ # the full path to the pwsh executable.
44
+ pwsh = Pwsh::Manager.instance(Pwsh::Manager.pwsh_path, Pwsh::Manager.pwsh_args)
45
+ ```
46
+
47
+ Execution can be done with relatively little additional work - pass the command string you want executed:
48
+
49
+ ```ruby
50
+ # Instantiate the Manager:
51
+ posh = Pwsh::Manager.instance(Pwsh::Manager.powershell_path, Pwsh::Manager.powershell_args)
52
+ # Pretty print the output of `$PSVersionTable` to validate the version of PowerShell running
53
+ # Note that the output is a hash with a few different keys, including stdout.
54
+ pp(posh.execute('$PSVersionTable'))
55
+ # Lets reduce the noise a little and retrieve just the version number:
56
+ # Note: We cast to a string because PSVersion is actually a Version object.
57
+ pp(posh.execute('[String]$PSVersionTable.PSVersion'))
58
+ # We could store this output to a ruby variable if we wanted, for further use:
59
+ ps_version = posh.execute('[String]$PSVersionTable.PSVersion')[:stdout].strip
60
+ pp("The PowerShell version of the currently running Manager is #{ps_version}")
61
+ ```
62
+
63
+ <!-- ## Development
64
+
65
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
66
+
67
+ To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and tags, and push the `.gem` file to [rubygems.org](https://rubygems.org). -->
68
+
69
+ ## Known Issues
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rubocop/rake_task'
4
+ require 'github_changelog_generator/task'
5
+ require 'pwsh/version'
6
+ require 'rspec/core/rake_task'
7
+ require 'yard'
8
+
9
+ GitHubChangelogGenerator::RakeTask.new :changelog do |config|
10
+ config.user = 'puppetlabs'
11
+ config.project = 'ruby-pwsh'
12
+ config.future_release = Pwsh::VERSION
13
+ config.since_tag = '0.0.1'
14
+ config.exclude_labels = ['maintenance']
15
+ config.header = "# Change log\n\nAll notable changes to this project will be documented in this file." \
16
+ 'The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) and this project adheres to [Semantic Versioning](http://semver.org).'
17
+ config.add_pr_wo_labels = true
18
+ config.issues = false
19
+ config.merge_prefix = '### UNCATEGORIZED PRS; GO LABEL THEM'
20
+ config.configure_sections = {
21
+ 'Changed' => {
22
+ 'prefix' => '### Changed',
23
+ 'labels' => %w[backwards-incompatible]
24
+ },
25
+ 'Added' => {
26
+ 'prefix' => '### Added',
27
+ 'labels' => %w[feature enhancement]
28
+ },
29
+ 'Fixed' => {
30
+ 'prefix' => '### Fixed',
31
+ 'labels' => %w[bugfix]
32
+ }
33
+ }
34
+ end
35
+
36
+ RuboCop::RakeTask.new(:rubocop) do |task|
37
+ task.options = %w[-D -S -E]
38
+ end
39
+
40
+ RSpec::Core::RakeTask.new(:spec)
41
+ task default: :spec
42
+
43
+ YARD::Rake::YardocTask.new do |t|
44
+ end
Binary file
@@ -0,0 +1,653 @@
1
+ # frozen_string_literal: false
2
+
3
+ require 'pwsh/util'
4
+ require 'pwsh/version'
5
+ require 'pwsh/windows_powershell'
6
+ require 'rexml/document'
7
+ require 'securerandom'
8
+ require 'socket'
9
+ require 'open3'
10
+ require 'base64'
11
+ require 'logger'
12
+
13
+ # Manage PowerShell and Windows PowerShell via ruby
14
+ module Pwsh
15
+ # Standard errors
16
+ class Error < StandardError; end
17
+ # Create an instance of a PowerShell host and manage execution of PowerShell code inside that host.
18
+ class Manager
19
+ attr_reader :powershell_command
20
+ attr_reader :powershell_arguments
21
+
22
+ # We actually want this to be a class variable.
23
+ @@instances = {} # rubocop:disable Style/ClassVars
24
+
25
+ # Return the list of currently instantiated instances of the PowerShell Manager
26
+ # @return [Hash] the list of instantiated instances of the PowerShell Manager, including their params and status.
27
+ def self.instances
28
+ @@instances
29
+ end
30
+
31
+ # Returns a set of default options for instantiating a manager
32
+ #
33
+ # @return [Hash] the default options for a new manager
34
+ def self.default_options
35
+ {
36
+ debug: false,
37
+ pipe_timeout: 30
38
+ }
39
+ end
40
+
41
+ # Return an instance of the manager if one already exists for the specified
42
+ # options or instantiate a new one if needed
43
+ #
44
+ # @param cmd [String] the full path to the PowerShell executable to manage
45
+ # @param args [Array] the list of additional arguments to pass PowerShell
46
+ # @param options [Hash] the set of options to set the behavior of the manager, including debug/timeout
47
+ # @return [] specific instance matching the specified parameters either newly created or previously instantiated
48
+ def self.instance(cmd, args, options = {})
49
+ options = default_options.merge!(options)
50
+
51
+ key = instance_key(cmd, args, options)
52
+ manager = @@instances[key]
53
+
54
+ if manager.nil? || !manager.alive?
55
+ # ignore any errors trying to tear down this unusable instance
56
+ begin
57
+ manager&.exit
58
+ rescue
59
+ nil
60
+ end
61
+ @@instances[key] = Manager.new(cmd, args, options)
62
+ end
63
+
64
+ @@instances[key]
65
+ end
66
+
67
+ # Determine whether or not the Win32 Console is enabled
68
+ #
69
+ # @return [Bool] true if enabled
70
+ def self.win32console_enabled?
71
+ @win32console_enabled ||= defined?(Win32) &&
72
+ defined?(Win32::Console) &&
73
+ Win32::Console.class == Class
74
+ end
75
+
76
+ # TODO: This thing isn't called anywhere and the variable it sets is never referenced...
77
+ # Determine whether or not the machine has a compatible version of Windows PowerShell
78
+ #
79
+ # @return [Bool] true if Windows PowerShell 3+ is available or 2+ with .NET 3.5SP1
80
+ # def self.compatible_version_of_windows_powershell?
81
+ # @compatible_version_of_powershell ||= Pwsh::WindowsPowerShell.compatible_version?
82
+ # end
83
+
84
+ # Determine whether or not the manager is supported on the machine for Windows PowerShell
85
+ #
86
+ # @return [Bool] true if Windows PowerShell is manageable
87
+ def self.windows_powershell_supported?
88
+ Pwsh::Util.on_windows? &&
89
+ Pwsh::WindowsPowerShell.compatible_version? &&
90
+ !win32console_enabled?
91
+ end
92
+
93
+ # Determine whether or not the manager is supported on the machine for PowerShell 6+
94
+ #
95
+ # @return [Bool] true if pwsh is manageable
96
+ def self.pwsh_supported?
97
+ !win32console_enabled?
98
+ end
99
+
100
+ # Instantiate a new instance of the PowerShell Manager
101
+ #
102
+ # @param cmd [String]
103
+ # @param args [Array]
104
+ # @param options [Hash]
105
+ # @return nil
106
+ def initialize(cmd, args = [], options = {})
107
+ @usable = true
108
+ @powershell_command = cmd
109
+ @powershell_arguments = args
110
+
111
+ raise "Bad configuration for ENV['lib']=#{ENV['lib']} - invalid path" if Pwsh::Util.invalid_directories?(ENV['lib'])
112
+
113
+ if Pwsh::Util.on_windows?
114
+ # Named pipes under Windows will automatically be mounted in \\.\pipe\...
115
+ # https://github.com/dotnet/corefx/blob/a10890f4ffe0fadf090c922578ba0e606ebdd16c/src/System.IO.Pipes/src/System/IO/Pipes/NamedPipeServerStream.Windows.cs#L34
116
+ named_pipe_name = "#{SecureRandom.uuid}PsHost"
117
+ # This named pipe path is Windows specific.
118
+ pipe_path = "\\\\.\\pipe\\#{named_pipe_name}"
119
+ else
120
+ # .Net implements named pipes under Linux etc. as Unix Sockets in the filesystem
121
+ # Paths that are rooted are not munged within C# Core.
122
+ # https://github.com/dotnet/corefx/blob/94e9d02ad70b2224d012ac4a66eaa1f913ae4f29/src/System.IO.Pipes/src/System/IO/Pipes/PipeStream.Unix.cs#L49-L60
123
+ # https://github.com/dotnet/corefx/blob/a10890f4ffe0fadf090c922578ba0e606ebdd16c/src/System.IO.Pipes/src/System/IO/Pipes/NamedPipeServerStream.Unix.cs#L44
124
+ # https://github.com/dotnet/corefx/blob/a10890f4ffe0fadf090c922578ba0e606ebdd16c/src/System.IO.Pipes/src/System/IO/Pipes/NamedPipeServerStream.Unix.cs#L298-L299
125
+ named_pipe_name = File.join(Dir.tmpdir, "#{SecureRandom.uuid}PsHost")
126
+ pipe_path = named_pipe_name
127
+ end
128
+ pipe_timeout = options[:pipe_timeout] || self.class.default_options[:pipe_timeout]
129
+ debug = options[:debug] || self.class.default_options[:debug]
130
+ native_cmd = Pwsh::Util.on_windows? ? "\"#{cmd}\"" : cmd
131
+
132
+ ps_args = args + ['-File', self.class.template_path, "\"#{named_pipe_name}\""]
133
+ ps_args << '"-EmitDebugOutput"' if debug
134
+ # @stderr should never be written to as PowerShell host redirects output
135
+ stdin, @stdout, @stderr, @ps_process = Open3.popen3("#{native_cmd} #{ps_args.join(' ')}")
136
+ stdin.close
137
+
138
+ # Puppet.debug "#{Time.now} #{cmd} is running as pid: #{@ps_process[:pid]}"
139
+
140
+ # Wait up to 180 seconds in 0.2 second intervals to be able to open the pipe.
141
+ # If the pipe_timeout is ever specified as less than the sleep interval it will
142
+ # never try to connect to a pipe and error out as if a timeout occurred.
143
+ sleep_interval = 0.2
144
+ (pipe_timeout / sleep_interval).to_int.times do
145
+ begin
146
+ @pipe = if Pwsh::Util.on_windows?
147
+ # Pipe is opened in binary mode and must always <- always what??
148
+ File.open(pipe_path, 'r+b')
149
+ else
150
+ UNIXSocket.new(pipe_path)
151
+ end
152
+ break
153
+ rescue
154
+ sleep sleep_interval
155
+ end
156
+ end
157
+ if @pipe.nil?
158
+ # Tear down and kill the process if unable to connect to the pipe; failure to do so
159
+ # results in zombie processes being left after the puppet run. We discovered that
160
+ # closing @ps_process via .kill instead of using this method actually kills the
161
+ # watcher and leaves an orphaned process behind. Failing to close stdout and stderr
162
+ # also leaves clutter behind, so explicitly close those too.
163
+ @stdout.close unless @stdout.closed?
164
+ @stderr.close unless @stderr.closed?
165
+ Process.kill('KILL', @ps_process[:pid]) if @ps_process.alive?
166
+ raise "Failure waiting for PowerShell process #{@ps_process[:pid]} to start pipe server"
167
+ end
168
+ # Puppet.debug "#{Time.now} PowerShell initialization complete for pid: #{@ps_process[:pid]}"
169
+
170
+ at_exit { exit }
171
+ end
172
+
173
+ # Return whether or not the manager is running, usable, and the I/O streams remain open.
174
+ #
175
+ # @return [Bool] true if manager is in working state
176
+ def alive?
177
+ # powershell process running
178
+ @ps_process.alive? &&
179
+ # explicitly set during a read / write failure, like broken pipe EPIPE
180
+ @usable &&
181
+ # an explicit failure state might not have been hit, but IO may be closed
182
+ self.class.stream_valid?(@pipe) &&
183
+ self.class.stream_valid?(@stdout) &&
184
+ self.class.stream_valid?(@stderr)
185
+ end
186
+
187
+ # Run specified powershell code via the manager
188
+ #
189
+ # @param powershell_code [String]
190
+ # @param timeout_ms [Int]
191
+ # @param working_dir [String]
192
+ # @param environment_variables [Hash]
193
+ # @return [Hash] Hash containing exitcode, stderr, native_stdout and stdout
194
+ def execute(powershell_code, timeout_ms = nil, working_dir = nil, environment_variables = [])
195
+ code = make_ps_code(powershell_code, timeout_ms, working_dir, environment_variables)
196
+ # err is drained stderr pipe (not captured by redirection inside PS)
197
+ # or during a failure, a Ruby callstack array
198
+ out, native_stdout, err = exec_read_result(code)
199
+
200
+ # an error was caught during execution that has invalidated any results
201
+ return { exitcode: -1, stderr: err } if out.nil? && !@usable
202
+
203
+ out[:exitcode] = out[:exitcode].to_i unless out[:exitcode].nil?
204
+ # If err contains data it must be "real" stderr output
205
+ # which should be appended to what PS has already captured
206
+ out[:stderr] = out[:stderr].nil? ? [] : [out[:stderr]]
207
+ out[:stderr] += err unless err.nil?
208
+ out[:native_stdout] = native_stdout
209
+
210
+ out
211
+ end
212
+
213
+ # TODO: Is this needed in the code manager? When brought into the module, should this be
214
+ # added as helper code leveraging this gem?
215
+ # Executes PowerShell code using the settings from a populated Puppet Exec Resource Type
216
+ # def execute_resource(powershell_code, working_dir, timeout_ms, environment)
217
+ # working_dir = resource[:cwd]
218
+ # if (!working_dir.nil?)
219
+ # fail "Working directory '#{working_dir}' does not exist" unless File.directory?(working_dir)
220
+ # end
221
+ # timeout_ms = resource[:timeout].nil? ? nil : resource[:timeout] * 1000
222
+ # environment_variables = resource[:environment].nil? ? [] : resource[:environment]
223
+
224
+ # result = execute(powershell_code, timeout_ms, working_dir, environment_variables)
225
+
226
+ # stdout = result[:stdout]
227
+ # native_out = result[:native_out]
228
+ # stderr = result[:stderr]
229
+ # exit_code = result[:exit_code]
230
+
231
+ # # unless stderr.nil?
232
+ # # stderr.each { |e| Puppet.debug "STDERR: #{e.chop}" unless e.empty? }
233
+ # # end
234
+
235
+ # # Puppet.debug "STDERR: #{result[:errormessage]}" unless result[:errormessage].nil?
236
+
237
+ # output = Puppet::Util::Execution::ProcessOutput.new(stdout.to_s + native_out.to_s, exit_code)
238
+
239
+ # return output, output
240
+ # end
241
+
242
+ # Tear down the instance of the manager, shutting down the pipe and process.
243
+ #
244
+ # @return nil
245
+ def exit
246
+ @usable = false
247
+
248
+ # Puppet.debug "Pwsh exiting..."
249
+
250
+ # Ask PowerShell pipe server to shutdown if its still running
251
+ # rather than expecting the pipe.close to terminate it
252
+ begin
253
+ write_pipe(pipe_command(:exit)) unless @pipe.closed?
254
+ rescue
255
+ nil
256
+ end
257
+
258
+ # Pipe may still be open, but if stdout / stderr are deat the PS
259
+ # process is in trouble and will block forever on a write to the
260
+ # pipe. It's safer to close pipe on the Ruby side, which gracefully
261
+ # shuts down the PS side.
262
+ @pipe.close unless @pipe.closed?
263
+ @stdout.close unless @stdout.closed?
264
+ @stderr.close unless @stderr.closed?
265
+
266
+ # Wait up to 2 seconds for the watcher thread to full exit
267
+ @ps_process.join(2)
268
+ end
269
+
270
+ # Return the path to the bootstrap template
271
+ #
272
+ # @return [String] full path to the bootstrap template
273
+ def self.template_path
274
+ # A PowerShell -File compatible path to bootstrap the instance
275
+ path = File.expand_path('../templates', __FILE__)
276
+ path = File.join(path, 'init.ps1').gsub('/', '\\')
277
+ "\"#{path}\""
278
+ end
279
+
280
+ # Return the block of code to be run by the manager with appropriate settings
281
+ #
282
+ # @param powershell_code [String] the actual PowerShell code you want to run
283
+ # @param timeout_ms [Int] the number of milliseconds to wait for the command to run
284
+ # @param working_dir [String] the working directory for PowerShell to execute from within
285
+ # @param environment_variables [Array] Any overrides for environment variables you want to specify
286
+ # @return [String] PowerShell code to be executed via the manager with appropriate params per config.
287
+ def make_ps_code(powershell_code, timeout_ms = nil, working_dir = nil, environment_variables = [])
288
+ begin
289
+ # Zero timeout is a special case. Other modules sometimes treat this
290
+ # as an infinite timeout. We don't support infinite, so for the case
291
+ # of a user specifying zero, we sub in the default value of 300s.
292
+ timeout_ms = 300 * 1000 if timeout_ms.zero?
293
+
294
+ timeout_ms = Integer(timeout_ms)
295
+
296
+ # Lower bound protection. The polling resolution is only 50ms.
297
+ timeout_ms = 50 if timeout_ms < 50
298
+ rescue
299
+ timeout_ms = 300 * 1000
300
+ end
301
+
302
+ # Environment array firstly needs to be parsed and converted into a hashtable.
303
+ # And then the values passed in need to be converted to a PowerShell Hashtable.
304
+ #
305
+ # Environment parsing is based on the puppet exec equivalent code
306
+ # https://github.com/puppetlabs/puppet/blob/a9f77d71e992fc2580de7705847e31264e0fbebe/lib/puppet/provider/exec.rb#L35-L49
307
+ environment = {}
308
+ if (envlist = environment_variables)
309
+ envlist = [envlist] unless envlist.is_a? Array
310
+ envlist.each do |setting|
311
+ if setting =~ /^(\w+)=((.|\n)+)$/
312
+ env_name = Regexp.last_match(1)
313
+ value = Regexp.last_match(2)
314
+ if environment.include?(env_name) || environment.include?(env_name.to_sym)
315
+ # Puppet.warning("Overriding environment setting '#{env_name}' with '#{value}'")
316
+ end
317
+ environment[env_name] = value
318
+ else # rubocop:disable Style/EmptyElse
319
+ # TODO: Implement logging
320
+ # Puppet.warning("Cannot understand environment setting #{setting.inspect}")
321
+ end
322
+ end
323
+ end
324
+ # Convert the Ruby Hashtable into PowerShell syntax
325
+ exec_environment_variables = '@{'
326
+ unless environment.empty?
327
+ environment.each do |name, value|
328
+ # PowerShell escapes single quotes inside a single quoted string by just adding
329
+ # another single quote i.e. a value of foo'bar turns into 'foo''bar' when single quoted.
330
+ ps_name = name.gsub('\'', '\'\'')
331
+ ps_value = value.gsub('\'', '\'\'')
332
+ exec_environment_variables += " '#{ps_name}' = '#{ps_value}';"
333
+ end
334
+ end
335
+ exec_environment_variables += '}'
336
+
337
+ # PS Side expects Invoke-PowerShellUserCode is always the return value here
338
+ # TODO: Refactor to use <<~ as soon as we can :sob:
339
+ <<-CODE
340
+ $params = @{
341
+ Code = @'
342
+ #{powershell_code}
343
+ '@
344
+ TimeoutMilliseconds = #{timeout_ms}
345
+ WorkingDirectory = "#{working_dir}"
346
+ ExecEnvironmentVariables = #{exec_environment_variables}
347
+ }
348
+
349
+ Invoke-PowerShellUserCode @params
350
+ CODE
351
+ end
352
+
353
+ # Default arguments for running Windows PowerShell via the manager
354
+ #
355
+ # @return [Array[String]] array of command flags to pass Windows PowerShell
356
+ def self.powershell_args
357
+ ps_args = ['-NoProfile', '-NonInteractive', '-NoLogo', '-ExecutionPolicy', 'Bypass']
358
+ ps_args << '-Command' unless windows_powershell_supported?
359
+
360
+ ps_args
361
+ end
362
+
363
+ # The path to Windows PowerShell on the system
364
+ #
365
+ # @return [String] the absolute path to the PowerShell executable. Returns 'powershell.exe' if no more specific path found.
366
+ def self.powershell_path
367
+ if File.exist?("#{ENV['SYSTEMROOT']}\\sysnative\\WindowsPowershell\\v1.0\\powershell.exe")
368
+ "#{ENV['SYSTEMROOT']}\\sysnative\\WindowsPowershell\\v1.0\\powershell.exe"
369
+ elsif File.exist?("#{ENV['SYSTEMROOT']}\\system32\\WindowsPowershell\\v1.0\\powershell.exe")
370
+ "#{ENV['SYSTEMROOT']}\\system32\\WindowsPowershell\\v1.0\\powershell.exe"
371
+ else
372
+ 'powershell.exe'
373
+ end
374
+ end
375
+
376
+ # Retrieves the absolute path to pwsh
377
+ #
378
+ # @return [String] the absolute path to the found pwsh executable. Returns nil when it does not exist
379
+ def self.pwsh_path(additional_paths = [])
380
+ # Environment variables on Windows are not case sensitive however ruby hash keys are.
381
+ # Convert all the key names to upcase so we can be sure to find PATH etc.
382
+ # Also while ruby can have difficulty changing the case of some UTF8 characters, we're
383
+ # only going to use plain ASCII names so this is safe.
384
+ current_path = Pwsh::Util.on_windows? ? ENV.select { |k, _| k.upcase == 'PATH' }.values[0] : ENV['PATH']
385
+ current_path = '' if current_path.nil?
386
+
387
+ # Prefer any additional paths
388
+ # TODO: Should we just use arrays by now instead of appending strings?
389
+ search_paths = additional_paths.empty? ? current_path : additional_paths.join(File::PATH_SEPARATOR) + File::PATH_SEPARATOR + current_path
390
+
391
+ # If we're on Windows, try the default installation locations as a last resort.
392
+ # https://docs.microsoft.com/en-us/powershell/scripting/install/installing-powershell-core-on-windows?view=powershell-6#msi
393
+ if Pwsh::Util.on_windows?
394
+ # TODO: What about PS 7? or 8?
395
+ # TODO: Need to check on French/Turkish windows if ENV['PROGRAMFILES'] parses UTF8 names correctly
396
+ # TODO: Need to ensure ENV['PROGRAMFILES'] is case insensitive, i.e. ENV['PROGRAMFiles'] should also resolve on Windows
397
+ search_paths += ";#{ENV['PROGRAMFILES']}\\PowerShell\\6" \
398
+ ";#{ENV['PROGRAMFILES(X86)']}\\PowerShell\\6"
399
+ end
400
+ raise 'No paths discovered to search for Powershell!' if search_paths.split(File::PATH_SEPARATOR).empty?
401
+
402
+ pwsh_paths = []
403
+ # TODO: THis could probably be done better, but it works!
404
+ if Pwsh::Util.on_windows?
405
+ search_paths.split(File::PATH_SEPARATOR).each do |path|
406
+ pwsh_paths << File.join(path, 'pwsh.exe') if File.exist?(File.join(path, 'pwsh.exe'))
407
+ end
408
+ else
409
+ search_paths.split(File::PATH_SEPARATOR).each do |path|
410
+ pwsh_paths << File.join(path, 'pwsh') if File.exist?(File.join(path, 'pwsh'))
411
+ end
412
+ end
413
+
414
+ # TODO: not sure about nil? but .empty? is MethodNotFound on nil
415
+ raise 'No pwsh discovered!' if pwsh_paths.nil? || pwsh_paths.empty?
416
+
417
+ pwsh_paths[0]
418
+ end
419
+
420
+ # Default arguments for running PowerShell 6+ via the manager
421
+ #
422
+ # @return [Array[String]] array of command flags to pass PowerShell 6+
423
+ def self.pwsh_args
424
+ ['-NoProfile', '-NonInteractive', '-NoLogo', '-ExecutionPolicy', 'Bypass']
425
+ end
426
+
427
+ # The unique key for a given manager as determined by the full path to
428
+ # the executable, the arguments to pass to the executable, and the options
429
+ # specified for the manager; this enables the code to reuse an existing
430
+ # manager if the same path, arguments, and options are specified.
431
+ #
432
+ # @return[String] Unique string representing the manager instance.
433
+ def self.instance_key(cmd, args, options)
434
+ cmd + args.join(' ') + options[:debug].to_s
435
+ end
436
+
437
+ # Return whether or not a particular stream is valid and readable
438
+ #
439
+ # @return [Bool] true if stream is readable and open
440
+ def self.readable?(stream, timeout = 0.5)
441
+ raise Errno::EPIPE unless stream_valid?(stream)
442
+
443
+ read_ready = IO.select([stream], [], [], timeout)
444
+ read_ready && stream == read_ready[0][0] && !stream.eof?
445
+ end
446
+
447
+ # When a stream has been closed by handle, but Ruby still has a file
448
+ # descriptor for it, it can be tricky to detemine that it's actually
449
+ # dead. The .fileno will still return an int, and calling get_osfhandle
450
+ # against it returns what the CRT thinks is a valid Windows HANDLE value,
451
+ # but that may no longer exist.
452
+ #
453
+ # @return [Bool] true if stream is open and operational
454
+ def self.stream_valid?(stream)
455
+ # When a stream is closed, it's obviously invalid, but Ruby doesn't always know
456
+ !stream.closed? &&
457
+ # So calling stat will yield and EBADF when underlying OS handle is bad
458
+ # as this resolves to a HANDLE and then calls the Windows API
459
+ !stream.stat.nil?
460
+ # Any exceptions mean the stream is dead
461
+ rescue
462
+ false
463
+ end
464
+
465
+ # The manager sends a 4-byte integer representing the number
466
+ # of bytes to read for the incoming string. This method reads
467
+ # that prefix and then reads the specified number of bytes.
468
+ # Mutates the given bytes, removing the length prefixed value.
469
+ #
470
+ # @return [String] The UTF-8 encoded string containing the payload
471
+ def self.read_length_prefixed_string!(bytes)
472
+ # 32 bit integer in Little Endian format
473
+ length = bytes.slice!(0, 4).unpack('V').first
474
+ return nil if length.zero?
475
+
476
+ bytes.slice!(0, length).force_encoding(Encoding::UTF_8)
477
+ end
478
+
479
+ # Takes a given input byte-stream from PowerShell, length-prefixed,
480
+ # and reads the key-value pairs from that output until all the
481
+ # information is retrieved. Mutates the given bytes.
482
+ #
483
+ # @return [Hash] String pairs representing the information passed
484
+ def self.ps_output_to_hash!(bytes)
485
+ hash = {}
486
+
487
+ hash[read_length_prefixed_string!(bytes).to_sym] = read_length_prefixed_string!(bytes) until bytes.empty?
488
+
489
+ hash
490
+ end
491
+
492
+ # This is the command that the ruby process will send to the PowerShell
493
+ # process and utilizes a 1 byte command identifier
494
+ # 0 - Exit
495
+ # 1 - Execute
496
+ #
497
+ # @return[String] Single byte representing the specified command
498
+ def pipe_command(command)
499
+ case command
500
+ when :exit
501
+ "\x00"
502
+ when :execute
503
+ "\x01"
504
+ end
505
+ end
506
+
507
+ # Take a given string and prefix it with a 4-byte length and encode for sending
508
+ # to the PowerShell manager.
509
+ # Data format is:
510
+ # 4 bytes - Little Endian encoded 32-bit integer length of string
511
+ # Intel CPUs are little endian, hence the .NET Framework typically is
512
+ # variable length - UTF8 encoded string bytes
513
+ #
514
+ # @return[String] A binary encoded string prefixed with a 4-byte length identifier
515
+ def length_prefixed_string(data)
516
+ msg = data.encode(Encoding::UTF_8)
517
+ # https://ruby-doc.org/core-1.9.3/Array.html#method-i-pack
518
+ [msg.bytes.length].pack('V') + msg.force_encoding(Encoding::BINARY)
519
+ end
520
+
521
+ # Writes binary-encoded data to the PowerShell manager process via the pipe.
522
+ #
523
+ # @return nil
524
+ def write_pipe(input)
525
+ # For Compat with Ruby 2.1 and lower, it's important to use syswrite and
526
+ # not write - otherwise, the pipe breaks after writing 1024 bytes.
527
+ written = @pipe.syswrite(input)
528
+ @pipe.flush
529
+
530
+ if written != input.length # rubocop:disable Style/GuardClause
531
+ msg = "Only wrote #{written} out of #{input.length} expected bytes to PowerShell pipe"
532
+ raise Errno::EPIPE.new, msg
533
+ end
534
+ end
535
+
536
+ # Read output from the PowerShell manager process via the pipe.
537
+ #
538
+ # @param pipe [IO] I/O Pipe to read from
539
+ # @param timeout [Float] The number of seconds to wait for the pipe to be readable
540
+ # @yield [String] a binary encoded string chunk
541
+ # @return nil
542
+ def read_from_pipe(pipe, timeout = 0.1, &_block)
543
+ if self.class.readable?(pipe, timeout)
544
+ l = pipe.readpartial(4096)
545
+ # Puppet.debug "#{Time.now} PIPE> #{l}"
546
+ # Since readpartial may return a nil at EOF, skip returning that value
547
+ yield l unless l.nil?
548
+ end
549
+
550
+ nil
551
+ end
552
+
553
+ # Read from a specified pipe for as long as the signal is locked and
554
+ # the pipe is readable. Then return the data as an array of UTF-8 strings.
555
+ #
556
+ # @param pipe [IO] the I/O pipe to read
557
+ # @param signal [Mutex] the signal to wait for whilst reading data
558
+ # @return [Array] An empty array if no data read or an array wrapping a single UTF-8 string if output received.
559
+ def drain_pipe_until_signaled(pipe, signal)
560
+ output = []
561
+
562
+ read_from_pipe(pipe) { |s| output << s } while signal.locked?
563
+
564
+ # There's ultimately a bit of a race here
565
+ # Read one more time after signal is received
566
+ read_from_pipe(pipe, 0) { |s| output << s } while self.class.readable?(pipe)
567
+
568
+ # String has been binary up to this point, so force UTF-8 now
569
+ output == [] ? [] : [output.join('').force_encoding(Encoding::UTF_8)]
570
+ end
571
+
572
+ # Open threads and pipes to read stdout and stderr from the PowerShell manager,
573
+ # then continue to read data from the manager until either all data is returned
574
+ # or an error interrupts the normal flow, then return that data.
575
+ #
576
+ # @return [Array] Array of three strings representing the output, native stdout, and stderr
577
+ def read_streams
578
+ pipe_done_reading = Mutex.new
579
+ pipe_done_reading.lock
580
+ # TODO: Uncomment again when implementing logging
581
+ # start_time = Time.now
582
+
583
+ stdout_reader = Thread.new { drain_pipe_until_signaled(@stdout, pipe_done_reading) }
584
+ stderr_reader = Thread.new { drain_pipe_until_signaled(@stderr, pipe_done_reading) }
585
+
586
+ pipe_reader = Thread.new(@pipe) do |pipe|
587
+ # Read a Little Endian 32-bit integer for length of response
588
+ expected_response_length = pipe.sysread(4).unpack('V').first
589
+
590
+ next nil if expected_response_length.zero?
591
+
592
+ # Reads the expected bytes as a binary string or fails
593
+ buffer = ''
594
+ # sysread may not return all of the requested bytes due to buffering or the
595
+ # underlying IO system. Keep reading from the pipe until all the bytes are read.
596
+ loop do
597
+ buffer.concat(pipe.sysread(expected_response_length - buffer.length))
598
+ break if buffer.length >= expected_response_length
599
+ end
600
+ buffer
601
+ end
602
+
603
+ # Puppet.debug "Waited #{Time.now - start_time} total seconds."
604
+
605
+ # Block until sysread has completed or errors
606
+ begin
607
+ output = pipe_reader.value
608
+ output = self.class.ps_output_to_hash!(output) unless output.nil?
609
+ ensure
610
+ # Signal stdout / stderr readers via Mutex so that
611
+ # Ruby doesn't crash waiting on an invalid event.
612
+ pipe_done_reading.unlock
613
+ end
614
+
615
+ # Given redirection on PowerShell side, this should always be empty
616
+ stdout = stdout_reader.value
617
+
618
+ [
619
+ output,
620
+ stdout == [] ? nil : stdout.join(''), # native stdout
621
+ stderr_reader.value # native stderr
622
+ ]
623
+ ensure
624
+ # Failsafe if the prior unlock was never reached / Mutex wasn't unlocked
625
+ pipe_done_reading.unlock if pipe_done_reading.locked?
626
+ # Wait for all non-nil threads to see mutex unlocked and finish
627
+ [pipe_reader, stdout_reader, stderr_reader].compact.each(&:join)
628
+ end
629
+
630
+ # Executes PowerShell code over the PowerShell manager and returns the results.
631
+ #
632
+ # @param powershell_code [String] The PowerShell code to execute via the manager
633
+ # @return [Array] Array of three strings representing the output, native stdout, and stderr
634
+ def exec_read_result(powershell_code)
635
+ write_pipe(pipe_command(:execute))
636
+ write_pipe(length_prefixed_string(powershell_code))
637
+ read_streams
638
+ # If any pipes are broken, the manager is totally hosed
639
+ # Bad file descriptors mean closed stream handles
640
+ # EOFError is a closed pipe (could be as a result of tearing down process)
641
+ # Errno::ECONNRESET is a closed unix domain socket (could be as a result of tearing down process)
642
+ rescue Errno::EPIPE, Errno::EBADF, EOFError, Errno::ECONNRESET => e
643
+ @usable = false
644
+ [nil, nil, [e.inspect, e.backtrace].flatten]
645
+ # Catch closed stream errors specifically
646
+ rescue IOError => e
647
+ raise unless e.message.start_with?('closed stream')
648
+
649
+ @usable = false
650
+ [nil, nil, [e.inspect, e.backtrace].flatten]
651
+ end
652
+ end
653
+ end