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.
- checksums.yaml +7 -0
- data/.gitattributes +2 -0
- data/.gitignore +15 -0
- data/.rspec +3 -0
- data/.rubocop.yml +31 -0
- data/.travis.yml +25 -0
- data/CODEOWNERS +2 -0
- data/DESIGN.md +70 -0
- data/Gemfile +51 -0
- data/LICENSE.txt +21 -0
- data/README.md +69 -0
- data/Rakefile +44 -0
- data/design-comms.png +0 -0
- data/lib/pwsh.rb +653 -0
- data/lib/pwsh/util.rb +54 -0
- data/lib/pwsh/version.rb +6 -0
- data/lib/pwsh/windows_powershell.rb +108 -0
- data/lib/ruby-pwsh.rb +3 -0
- data/lib/templates/init.ps1 +824 -0
- data/ruby-pwsh.gemspec +39 -0
- metadata +66 -0
checksums.yaml
ADDED
@@ -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
|
data/.gitattributes
ADDED
data/.gitignore
ADDED
data/.rspec
ADDED
data/.rubocop.yml
ADDED
@@ -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
|
data/.travis.yml
ADDED
@@ -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'
|
data/CODEOWNERS
ADDED
data/DESIGN.md
ADDED
@@ -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
|
+

|
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
|
data/LICENSE.txt
ADDED
@@ -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.
|
data/README.md
ADDED
@@ -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
|
data/Rakefile
ADDED
@@ -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
|
data/design-comms.png
ADDED
Binary file
|
data/lib/pwsh.rb
ADDED
@@ -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
|