ruby-pwsh 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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