ruby-pwsh 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|
+
![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
|
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
|