ruby-pwsh 0.7.4 → 0.10.1

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 57ca9a69869c173a437512ee463eb249ad7fd8926f8a04401a1cc3d2529fd113
4
- data.tar.gz: 36c7863b7dd49f40e78539832dbece96843b712b7bd33452a3af8be61e678d88
3
+ metadata.gz: bdcbcec4cceaa40f4fabaad50f9d7409382c14eb1a98a345af5770aa1ae78f31
4
+ data.tar.gz: 48f16a70a2305b398a9098d453ae743a6c2630a94cdc8d6716250e1e4ee83464
5
5
  SHA512:
6
- metadata.gz: eaa268a3795e5e87d34eeabf2ec64bc439755c9669459d5410bc3f074b354d8e1c2e3d42fbfa0bcc884b2d3dc9e260aa6c2058bd17d92cebc10d8c74d17eb585
7
- data.tar.gz: 3763124d44b953e8da9e52105c509a7e1f49ee78a7f26683d43ab428c391c9d7bdaab23ca7a62adefd10697060aecfdbdbda40795d73e7a5ca5199165b729537
6
+ metadata.gz: 7c19486fd5452d81c71e7939fddaa78c66972ddb425806fc894b1aeecf0cb94b917955d29245130095ee5c4c85d63f2ba12176b6ef8fe4ce7e6f9f653bb0224c
7
+ data.tar.gz: b12c1ab52269745152a29ce446876a3e06f3b9f9aab1d254157d8f93c6cccb1e7f997c118488229e22d8800a1a1a17af0557a8c0af2ea386d3e80e80c2f5bfd0
@@ -0,0 +1,109 @@
1
+ name: CI
2
+
3
+ on:
4
+ pull_request:
5
+ schedule:
6
+ - cron: "0 0 * * *"
7
+ workflow_dispatch:
8
+
9
+ jobs:
10
+ rubocop:
11
+ runs-on: ${{ matrix.os }}
12
+ strategy:
13
+ fail-fast: false
14
+ matrix:
15
+ os:
16
+ - windows-latest
17
+ - ubuntu-latest
18
+ ruby: ["2.7"]
19
+ steps:
20
+ - name: Checkout Source
21
+ uses: actions/checkout@v2
22
+ - name: Activate Ruby
23
+ uses: ruby/setup-ruby@v1
24
+ with:
25
+ ruby-version: ${{ matrix.ruby }}
26
+ bundler-cache: true
27
+ - name: Print Test Environment
28
+ run: |
29
+ ruby -v
30
+ gem -v
31
+ bundle -v
32
+ pwsh -v
33
+ - name: Run Rubocop Tests
34
+ run: |
35
+ bundle exec rake rubocop
36
+ spec:
37
+ runs-on: ${{ matrix.os }}
38
+ strategy:
39
+ fail-fast: false
40
+ matrix:
41
+ os:
42
+ - windows-latest
43
+ - windows-2016
44
+ - ubuntu-latest
45
+ - ubuntu-18.04
46
+ ruby: ["2.5", "2.7"]
47
+ steps:
48
+ - name: Checkout Source
49
+ uses: actions/checkout@v2
50
+ - name: Activate Ruby
51
+ uses: ruby/setup-ruby@v1
52
+ with:
53
+ ruby-version: ${{ matrix.ruby }}
54
+ bundler-cache: true
55
+ - name: Print Test Environment
56
+ run: |
57
+ ruby -v
58
+ gem -v
59
+ bundle -v
60
+ pwsh -v
61
+ - name: Run Spec Tests
62
+ run: |
63
+ bundle exec rake spec
64
+ acceptance-dsc:
65
+ runs-on: ${{ matrix.os }}
66
+ strategy:
67
+ fail-fast: false
68
+ matrix:
69
+ os:
70
+ - windows-latest
71
+ - windows-2016
72
+ puppet:
73
+ - 6
74
+ - 7
75
+ include:
76
+ - puppet: 6
77
+ ruby: 2.5
78
+ - puppet: 7
79
+ ruby: 2.7
80
+ env:
81
+ PUPPET_GEM_VERSION: ${{ matrix.puppet }}
82
+ steps:
83
+ - name: Checkout Source
84
+ uses: actions/checkout@v2
85
+ - name: Activate Ruby
86
+ uses: ruby/setup-ruby@v1
87
+ with:
88
+ ruby-version: ${{ matrix.ruby }}
89
+ bundler-cache: true
90
+ - name: Print Test Environment
91
+ run: |
92
+ ruby -v
93
+ gem -v
94
+ bundle -v
95
+ pwsh -v
96
+ - name: Ensure WinRM is working
97
+ shell: powershell
98
+ run: |
99
+ Get-ChildItem WSMan:\localhost\Listener\ -OutVariable Listeners | Format-List * -Force
100
+ $HTTPListener = $Listeners | Where-Object -FilterScript { $_.Keys.Contains('Transport=HTTP') }
101
+ If ($HTTPListener.Count -eq 0) {
102
+ winrm create winrm/config/Listener?Address=*+Transport=HTTP
103
+ winrm e winrm/config/listener
104
+ }
105
+ - name: Run Acceptance Tests
106
+ shell: powershell
107
+ run: |
108
+ bundle exec rake dsc:acceptance:spec_prep
109
+ bundle exec rake dsc:acceptance:spec
data/.gitignore CHANGED
@@ -15,4 +15,9 @@ Gemfile.local
15
15
  Gemfile.lock
16
16
 
17
17
  # build output
18
- /ruby-pwsh-*.gem
18
+ /ruby-pwsh-*.gem
19
+
20
+ # Acceptance Testing fixtures
21
+ /spec/fixtures/modules/
22
+ /spec/fixtures/test.pp
23
+ /spec/fixtures/website/
data/CHANGELOG.md CHANGED
@@ -2,7 +2,54 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.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).
4
4
 
5
- ## [0.7.4](https://github.com/puppetlabs/ruby-pwsh/tree/0.7.4) (2021-02-11)
5
+ ## [0.10.1](https://github.com/puppetlabs/ruby-pwsh/tree/0.10.1) (2021-08-23)
6
+
7
+ [Full Changelog](https://github.com/puppetlabs/ruby-pwsh/compare/0.10.0...0.10.1)
8
+
9
+ ### Fixed
10
+
11
+ - \(GH-180\) Ensure instance\_key respects full uniqueness of options [\#181](https://github.com/puppetlabs/ruby-pwsh/pull/181) ([michaeltlombardi](https://github.com/michaeltlombardi))
12
+ - \(GH-165\) Ensure null-value nested cim instance arrays are appropriately munged [\#177](https://github.com/puppetlabs/ruby-pwsh/pull/177) ([michaeltlombardi](https://github.com/michaeltlombardi))
13
+
14
+ ## [0.10.0](https://github.com/puppetlabs/ruby-pwsh/tree/0.10.0) (2021-07-02)
15
+
16
+ [Full Changelog](https://github.com/puppetlabs/ruby-pwsh/compare/0.9.0...0.10.0)
17
+
18
+ ### Added
19
+
20
+ - \(GH-172\) Enable use of class-based DSC Resources by munging PSModulePath [\#173](https://github.com/puppetlabs/ruby-pwsh/pull/173) ([michaeltlombardi](https://github.com/michaeltlombardi))
21
+
22
+ ## [0.9.0](https://github.com/puppetlabs/ruby-pwsh/tree/0.9.0) (2021-06-28)
23
+
24
+ [Full Changelog](https://github.com/puppetlabs/ruby-pwsh/compare/0.8.0...0.9.0)
25
+
26
+ ### Added
27
+
28
+ - \(GH-147\) Refactor Invocation methods to use shared helper and write error logs when appropriate [\#152](https://github.com/puppetlabs/ruby-pwsh/pull/152) ([david22swan](https://github.com/david22swan))
29
+ - \(GH-145\) Improve DSC secrets redaction [\#150](https://github.com/puppetlabs/ruby-pwsh/pull/150) ([michaeltlombardi](https://github.com/michaeltlombardi))
30
+ - \(GH-145\) Add insync? and invoke\_test\_method to dsc provider [\#124](https://github.com/puppetlabs/ruby-pwsh/pull/124) ([michaeltlombardi](https://github.com/michaeltlombardi))
31
+ - \(MAINT\) Clarify supported platforms [\#113](https://github.com/puppetlabs/ruby-pwsh/pull/113) ([michaeltlombardi](https://github.com/michaeltlombardi))
32
+
33
+ ### Fixed
34
+
35
+ - \(IAC-1657\) Fix for invalid DateTime value error in `invoke_get_method` [\#169](https://github.com/puppetlabs/ruby-pwsh/pull/169) ([david22swan](https://github.com/david22swan))
36
+ - \(GH-154\) Ensure values returned from `invoke_get_method` are recursively sorted in the DSC Base Provider to reduce canonicalization warnings. [\#160](https://github.com/puppetlabs/ruby-pwsh/pull/160) ([michaeltlombardi](https://github.com/michaeltlombardi))
37
+ - \(GH-154\) Fix return data from `Invoke-DscResource` for empty strings and single item arrays in DSC Base Provider [\#159](https://github.com/puppetlabs/ruby-pwsh/pull/159) ([michaeltlombardi](https://github.com/michaeltlombardi))
38
+ - \(GH-155\) Fix CIM Instance munging in `invoke_get_method` for DSC Base Provider [\#158](https://github.com/puppetlabs/ruby-pwsh/pull/158) ([michaeltlombardi](https://github.com/michaeltlombardi))
39
+ - \(GH-154\) Fix canonicalization in `get` method for DSC Base Provider [\#157](https://github.com/puppetlabs/ruby-pwsh/pull/157) ([michaeltlombardi](https://github.com/michaeltlombardi))
40
+ - \(GH-144\) Enable order-insensitive comparisons for DSC [\#151](https://github.com/puppetlabs/ruby-pwsh/pull/151) ([michaeltlombardi](https://github.com/michaeltlombardi))
41
+ - \(GH-143\) Handle order insensitive arrays in the `same?` method of the DSC Base Provider [\#148](https://github.com/puppetlabs/ruby-pwsh/pull/148) ([michaeltlombardi](https://github.com/michaeltlombardi))
42
+ - \(GH-127\) Canonicalize enums correctly [\#131](https://github.com/puppetlabs/ruby-pwsh/pull/131) ([michaeltlombardi](https://github.com/michaeltlombardi))
43
+ - \(GH-125\) Fix dsc provider canonicalization for absent resources [\#129](https://github.com/puppetlabs/ruby-pwsh/pull/129) ([michaeltlombardi](https://github.com/michaeltlombardi))
44
+ - \(MODULES-11051\) Ensure environment variables are not incorrectly munged in the PowerShell Host [\#128](https://github.com/puppetlabs/ruby-pwsh/pull/128) ([michaeltlombardi](https://github.com/michaeltlombardi))
45
+ - \(MODULES-11026\) Ensure the PowerShell manager works with v7 [\#122](https://github.com/puppetlabs/ruby-pwsh/pull/122) ([n3snah](https://github.com/n3snah))
46
+ - \(Maint\) Ensure canonicalize correctly compares sorted hashes [\#118](https://github.com/puppetlabs/ruby-pwsh/pull/118) ([Hvid](https://github.com/Hvid))
47
+
48
+ ## [0.8.0](https://github.com/puppetlabs/ruby-pwsh/tree/0.8.0) (2021-03-01)
49
+
50
+ [Full Changelog](https://github.com/puppetlabs/ruby-pwsh/compare/0.7.4...0.8.0)
51
+
52
+ ## [0.7.4](https://github.com/puppetlabs/ruby-pwsh/tree/0.7.4) (2021-02-12)
6
53
 
7
54
  [Full Changelog](https://github.com/puppetlabs/ruby-pwsh/compare/0.7.3...0.7.4)
8
55
 
@@ -106,7 +153,6 @@ All notable changes to this project will be documented in this file.The format i
106
153
  ### Added
107
154
 
108
155
  - \(IAC-1045\) Add the DSC base Puppet provider to pwshlib [\#39](https://github.com/puppetlabs/ruby-pwsh/pull/39) ([michaeltlombardi](https://github.com/michaeltlombardi))
109
- - \(MODULES-10389\) Add puppet feature for dependent modules to leverage [\#20](https://github.com/puppetlabs/ruby-pwsh/pull/20) ([sanfrancrisko](https://github.com/sanfrancrisko))
110
156
 
111
157
  ## [0.4.1](https://github.com/puppetlabs/ruby-pwsh/tree/0.4.1) (2020-02-13)
112
158
 
@@ -122,7 +168,7 @@ All notable changes to this project will be documented in this file.The format i
122
168
 
123
169
  ### Added
124
170
 
125
- - \(FM-8422\) Make library releasable as a Puppet module [\#8](https://github.com/puppetlabs/ruby-pwsh/pull/8) ([michaeltlombardi](https://github.com/michaeltlombardi))
171
+ - \(MODULES-10389\) Add puppet feature for dependent modules to leverage [\#20](https://github.com/puppetlabs/ruby-pwsh/pull/20) ([sanfrancrisko](https://github.com/sanfrancrisko))
126
172
 
127
173
  ## [0.3.0](https://github.com/puppetlabs/ruby-pwsh/tree/0.3.0) (2019-12-04)
128
174
 
@@ -143,6 +189,7 @@ All notable changes to this project will be documented in this file.The format i
143
189
  ### Added
144
190
 
145
191
  - \(FEAT\) Add quality of life utilities [\#11](https://github.com/puppetlabs/ruby-pwsh/pull/11) ([michaeltlombardi](https://github.com/michaeltlombardi))
192
+ - \(FM-8422\) Make library releasable as a Puppet module [\#8](https://github.com/puppetlabs/ruby-pwsh/pull/8) ([michaeltlombardi](https://github.com/michaeltlombardi))
146
193
 
147
194
 
148
195
 
data/Gemfile CHANGED
@@ -23,7 +23,11 @@ end
23
23
 
24
24
  group :puppet do
25
25
  gem 'pdk', '~> 1.0'
26
- gem 'puppet'
26
+ if ENV['PUPPET_GEM_VERSION']
27
+ gem 'puppet', "~> #{ENV['PUPPET_GEM_VERSION']}"
28
+ else
29
+ gem 'puppet'
30
+ end
27
31
  end
28
32
 
29
33
  group :pry do
data/README.md CHANGED
@@ -117,3 +117,15 @@ Steps to release an update to the gem and module include:
117
117
  1. Submit the [mergeback PR from the release branch to main](https://github.com/puppetlabs/ruby-pwsh/compare/main...release).
118
118
 
119
119
  ## Known Issues
120
+
121
+ ## Supported Operating Systems
122
+
123
+ The following platforms are supported:
124
+
125
+ - Windows
126
+ - CentOS
127
+ - Debian
128
+ - Fedora
129
+ - OSX
130
+ - RedHat
131
+ - Ubuntu
data/Rakefile CHANGED
@@ -98,3 +98,91 @@ task :build_module do
98
98
  # Cleanup
99
99
  File.open('README.md', 'wb') { |file| file.write(actual_readme_content) }
100
100
  end
101
+
102
+ # Used in vendor_dsc_module
103
+ TAR_LONGLINK = '././@LongLink'
104
+
105
+ # Vendor a Puppetized DSC Module to spec/fixtures/modules.
106
+ #
107
+ # This is necessary because `puppet module install` fails on modules with
108
+ # long file paths, like xpsdesiredstateconfiguration
109
+ #
110
+ # @param command [String] command to execute.
111
+ # @return [Object] the standard out stream.
112
+ def vendor_dsc_module(name, version, destination)
113
+ require 'open-uri'
114
+ require 'rubygems/package'
115
+ require 'zlib'
116
+
117
+ module_uri = "https://forge.puppet.com/v3/files/dsc-#{name}-#{version}.tar.gz"
118
+ tar_gz_archive = File.expand_path("#{name}.tar.gz", ENV['TEMP'])
119
+
120
+ # Download the archive from the forge
121
+ File.open(tar_gz_archive, 'wb') do |file|
122
+ file.write(URI.open(module_uri).read) # rubocop:disable Security/Open
123
+ end
124
+
125
+ # Unzip to destination
126
+ # Taken directly from StackOverflow:
127
+ # - https://stackoverflow.com/a/19139114
128
+ Gem::Package::TarReader.new(Zlib::GzipReader.open(tar_gz_archive)) do |tar|
129
+ dest = nil
130
+ tar.each do |entry|
131
+ if entry.full_name == TAR_LONGLINK
132
+ dest = File.join(destination, entry.read.strip)
133
+ next
134
+ end
135
+ dest ||= File.join(destination, entry.full_name)
136
+ if entry.directory?
137
+ File.delete(dest) if File.file?(dest)
138
+ FileUtils.mkdir_p(dest, mode: entry.header.mode, verbose: false)
139
+ elsif entry.file?
140
+ FileUtils.rm_rf(dest) if File.directory?(dest)
141
+ File.open(dest, 'wb') do |f|
142
+ f.print(entry.read)
143
+ end
144
+ FileUtils.chmod(entry.header.mode, dest, verbose: false)
145
+ elsif entry.header.typeflag == '2' # Symlink!
146
+ File.symlink(entry.header.linkname, dest)
147
+ end
148
+ dest = nil
149
+ end
150
+ end
151
+
152
+ # Rename folder to just the module name, as needed by Puppet
153
+ Dir.glob("#{destination}/*#{name}*").each do |existing_folder|
154
+ new_folder = File.expand_path(name, destination)
155
+ FileUtils.mv(existing_folder, new_folder)
156
+ end
157
+ end
158
+
159
+ namespace :dsc do
160
+ namespace :acceptance do
161
+ desc 'Prep for running DSC acceptance tests'
162
+ task :spec_prep do
163
+ # Create the modules fixture folder, if needed
164
+ modules_folder = File.expand_path('spec/fixtures/modules', File.dirname(__FILE__))
165
+ FileUtils.mkdir_p(modules_folder) unless Dir.exist?(modules_folder)
166
+ # symlink the parent folder to the modules folder for puppet
167
+ symlink_path = File.expand_path('pwshlib', modules_folder)
168
+ File.symlink(File.dirname(__FILE__), symlink_path) unless Dir.exist?(symlink_path)
169
+ # Install each of the required modules for acceptance testing
170
+ # Note: This only works for modules in the dsc namespace on the forge.
171
+ puppetized_dsc_modules = [
172
+ { name: 'powershellget', version: '2.2.5-0-1' },
173
+ { name: 'jeadsc', version: '0.7.2-0-2' }, # update to 0.7.2-0-3 on release
174
+ { name: 'xpsdesiredstateconfiguration', version: '9.1.0-0-1' },
175
+ { name: 'xwebadministration', version: '3.2.0-0-2' },
176
+ { name: 'accesscontroldsc', version: '1.4.1-0-3' }
177
+ ]
178
+ puppetized_dsc_modules.each do |puppet_module|
179
+ next if Dir.exist?(File.expand_path(puppet_module[:name], modules_folder))
180
+
181
+ vendor_dsc_module(puppet_module[:name], puppet_module[:version], modules_folder)
182
+ end
183
+ end
184
+ RSpec::Core::RakeTask.new(:spec) do |t|
185
+ t.pattern = 'spec/acceptance/dsc/*.rb'
186
+ end
187
+ end
188
+ end
@@ -13,10 +13,15 @@ class Puppet::Provider::DscBaseProvider
13
13
  def initialize
14
14
  @@cached_canonicalized_resource ||= []
15
15
  @@cached_query_results ||= []
16
+ @@cached_test_results ||= []
16
17
  @@logon_failures ||= []
17
18
  super
18
19
  end
19
20
 
21
+ def cached_test_results
22
+ @@cached_test_results
23
+ end
24
+
20
25
  # Look through a cache to retrieve the hashes specified, if they have been cached.
21
26
  # Does so by seeing if each of the specified hashes is a subset of any of the hashes
22
27
  # in the cache, so {foo: 1, bar: 2} would return if {foo: 1} was the search hash.
@@ -47,31 +52,50 @@ class Puppet::Provider::DscBaseProvider
47
52
  # During RSAPI refresh runs mandatory parameters are stripped and not available;
48
53
  # Instead of checking again and failing, search the cache for a namevar match.
49
54
  namevarized_r = r.select { |k, _v| namevar_attributes(context).include?(k) }
50
- if fetch_cached_hashes(@@cached_canonicalized_resource, [namevarized_r]).empty?
51
- canonicalized = invoke_get_method(context, r)
52
- if canonicalized.nil?
55
+ cached_result = fetch_cached_hashes(@@cached_canonicalized_resource, [namevarized_r]).first
56
+ if cached_result.nil?
57
+ # If the resource is meant to be absent, skip canonicalization and rely on the manifest
58
+ # value; there's no reason to compare system state to desired state for casing if the
59
+ # resource is being removed.
60
+ if r[:dsc_ensure] == 'absent'
53
61
  canonicalized = r.dup
54
62
  @@cached_canonicalized_resource << r.dup
55
63
  else
56
- parameters = r.select { |name, _properties| parameter_attributes(context).include?(name) }
57
- canonicalized.merge!(parameters)
58
- canonicalized[:name] = r[:name]
59
- if r[:dsc_psdscrunascredential].nil?
60
- canonicalized.delete(:dsc_psdscrunascredential)
64
+ canonicalized = invoke_get_method(context, r)
65
+ # If the resource could not be found or was returned as absent, skip case munging and
66
+ # treat the manifest values as canonical since the resource is being created.
67
+ # rubocop:disable Metrics/BlockNesting
68
+ if canonicalized.nil? || canonicalized[:dsc_ensure] == 'absent'
69
+ canonicalized = r.dup
70
+ @@cached_canonicalized_resource << r.dup
61
71
  else
62
- canonicalized[:dsc_psdscrunascredential] = r[:dsc_psdscrunascredential]
63
- end
64
- downcased_result = recursively_downcase(canonicalized)
65
- downcased_resource = recursively_downcase(r)
66
- downcased_result.each do |key, value|
67
- is_same = value.is_a?(Enumerable) & !downcased_resource[key].nil? ? downcased_resource[key].sort == value.sort : downcased_resource[key] == value
68
- canonicalized[key] = r[key] unless is_same
69
- canonicalized.delete(key) unless downcased_resource.keys.include?(key)
72
+ parameters = r.select { |name, _properties| parameter_attributes(context).include?(name) }
73
+ canonicalized.merge!(parameters)
74
+ canonicalized[:name] = r[:name]
75
+ if r[:dsc_psdscrunascredential].nil?
76
+ canonicalized.delete(:dsc_psdscrunascredential)
77
+ else
78
+ canonicalized[:dsc_psdscrunascredential] = r[:dsc_psdscrunascredential]
79
+ end
80
+ downcased_result = recursively_downcase(canonicalized)
81
+ downcased_resource = recursively_downcase(r)
82
+ downcased_result.each do |key, value|
83
+ # Canonicalize to the manifest value unless the downcased strings match and the attribute is not an enum:
84
+ # - When the values don't match at all, the manifest value is desired;
85
+ # - When the values match case insensitively but the attribute is an enum, prefer the casing of the manifest enum.
86
+ # - When the values match case insensitively and the attribute is not an enum, prefer the casing from invoke_get_method
87
+ canonicalized[key] = r[key] unless same?(value, downcased_resource[key]) && !enum_attributes(context).include?(key)
88
+ canonicalized.delete(key) unless downcased_resource.keys.include?(key)
89
+ end
90
+ # Cache the actually canonicalized resource separately
91
+ @@cached_canonicalized_resource << canonicalized.dup
70
92
  end
71
- # Cache the actually canonicalized resource separately
72
- @@cached_canonicalized_resource << canonicalized.dup
93
+ # rubocop:enable Metrics/BlockNesting
73
94
  end
74
95
  else
96
+ # The resource has already been canonicalized for the set values and is not being canonicalized for get
97
+ # In this case, we do *not* want to process anything, just return the resource. We only call canonicalize
98
+ # so we can get case insensitive but preserving values for _setting_ state.
75
99
  canonicalized = r
76
100
  end
77
101
  canonicalized_resources << canonicalized
@@ -92,7 +116,7 @@ class Puppet::Provider::DscBaseProvider
92
116
  # Relies on the get_simple_filter feature to pass the namevars
93
117
  # as an array containing the namevar parameters as a hash.
94
118
  # This hash is functionally the same as a should hash as
95
- # passed to the should_to_resource method.
119
+ # passed to the invocable_resource method.
96
120
  context.debug('Collecting data from the DSC Resource')
97
121
 
98
122
  # If the resource has already been queried, do not bother querying for it again
@@ -123,27 +147,14 @@ class Puppet::Provider::DscBaseProvider
123
147
  # @param changes [Hash] the hash of whose key is the name_hash and value is the is and should hashes
124
148
  def set(context, changes)
125
149
  changes.each do |name, change|
126
- is = change.key?(:is) ? change[:is] : (get(context, [name]) || []).find { |r| r[:name] == name }
127
- context.type.check_schema(is) unless change.key?(:is)
128
-
150
+ is = change[:is]
129
151
  should = change[:should]
130
152
 
131
- name_hash = if context.type.namevars.length > 1
132
- # pass a name_hash containing the values of all namevars
133
- name_hash = {}
134
- context.type.namevars.each do |namevar|
135
- name_hash[namevar] = change[:should][namevar]
136
- end
137
- name_hash
138
- else
139
- name
140
- end
153
+ # If should is an array instead of a hash and only has one entry, use that.
154
+ should = should.first if should.is_a?(Array) && should.length == 1
141
155
 
142
156
  # for compatibility sake, we use dsc_ensure instead of ensure, so context.type.ensurable? does not work
143
157
  if context.type.attributes.key?(:dsc_ensure)
144
- is = create_absent(:name, name) if is.nil?
145
- should = create_absent(:name, name) if should.nil?
146
-
147
158
  # HACK: If the DSC Resource is ensurable but doesn't report a default value
148
159
  # for ensure, we assume it to be `Present` - this is the most common pattern.
149
160
  should_ensure = should[:dsc_ensure].nil? ? 'Present' : should[:dsc_ensure].to_s
@@ -152,47 +163,31 @@ class Puppet::Provider::DscBaseProvider
152
163
 
153
164
  if is_ensure == 'Absent' && should_ensure == 'Present'
154
165
  context.creating(name) do
155
- create(context, name_hash, should)
166
+ create(context, name, should)
156
167
  end
157
168
  elsif is_ensure == 'Present' && should_ensure == 'Present'
158
169
  context.updating(name) do
159
- update(context, name_hash, should)
170
+ update(context, name, should)
160
171
  end
161
172
  elsif is_ensure == 'Present' && should_ensure == 'Absent'
162
173
  context.deleting(name) do
163
- delete(context, name_hash)
174
+ delete(context, name)
164
175
  end
165
176
  else
166
177
  # In this case we are not sure if the resource is being created/updated/removed
167
178
  # as with ensure "latest" or a specific version number, so default to update.
168
179
  context.updating(name) do
169
- update(context, name_hash, should)
180
+ update(context, name, should)
170
181
  end
171
182
  end
172
183
  else
173
184
  context.updating(name) do
174
- update(context, name_hash, should)
185
+ update(context, name, should)
175
186
  end
176
187
  end
177
188
  end
178
189
  end
179
190
 
180
- # Creates a hash with the name / name_hash and sets dsc_ensure to absent for comparison
181
- # purposes; this handles cases where the resource isn't found on the node.
182
- #
183
- # @param namevar [Object] the name of the variable being used for the resource name
184
- # @param title [Hash] the hash of namevar properties and their values
185
- # @return [Hash] returns a hash representing the absent state of the resource
186
- def create_absent(namevar, title)
187
- result = if title.is_a? Hash
188
- title.dup
189
- else
190
- { namevar => title }
191
- end
192
- result[:dsc_ensure] = 'Absent'
193
- result
194
- end
195
-
196
191
  # Attempts to set an instance of the DSC resource, invoking the `Set` method and thinly wrapping
197
192
  # the `invoke_set_method` method; whether this method, `update`, or `delete` is called is entirely
198
193
  # up to the Resource API based on the results
@@ -229,32 +224,40 @@ class Puppet::Provider::DscBaseProvider
229
224
  invoke_set_method(context, name, name.merge({ dsc_ensure: 'Absent' }))
230
225
  end
231
226
 
232
- # Invokes the `Get` method, passing the name_hash as the properties to use with `Invoke-DscResource`
233
- # The PowerShell script returns a JSON representation of the DSC Resource's CIM Instance munged as
234
- # best it can be for Ruby. Once that JSON is parsed into a hash this method further munges it to
235
- # fit the expected property definitions. Finally, it returns the object for the Resource API to
236
- # compare against and determine what future actions, if any, are needed.
227
+ # Invokes the given DSC method, passing the name_hash as the properties to use with `Invoke-DscResource`
228
+ # The PowerShell script returns a JSON hash with key-value pairs indicating the result of the given command.
229
+ # The hash is left untouched for the most part with any further parsing handled by the methods that call upon it.
237
230
  #
238
231
  # @param context [Object] the Puppet runtime context to operate in and send feedback to
239
232
  # @param name_hash [Hash] the hash of namevars to be passed as properties to `Invoke-DscResource`
240
- # @return [Hash] returns a hash representing the DSC resource munged to the representation the Puppet Type expects
241
- def invoke_get_method(context, name_hash)
242
- context.debug("retrieving #{name_hash.inspect}")
243
-
233
+ # @param props [Hash] the properties to be passed to `Invoke-DscResource`
234
+ # @param method [String] the method to be specified
235
+ # @return [Hash] returns a hash representing the result of the DSC resource call
236
+ def invoke_dsc_resource(context, name_hash, props, method)
244
237
  # Do not bother running if the logon credentials won't work
245
- return name_hash if !name_hash[:dsc_psdscrunascredential].nil? && logon_failed_already?(name_hash[:dsc_psdscrunascredential])
246
-
247
- query_props = name_hash.select { |k, v| mandatory_get_attributes(context).include?(k) || (k == :dsc_psdscrunascredential && !v.nil?) }
248
- resource = should_to_resource(query_props, context, 'get')
238
+ if !name_hash[:dsc_psdscrunascredential].nil? && logon_failed_already?(name_hash[:dsc_psdscrunascredential])
239
+ context.err('Logon credentials are invalid')
240
+ return nil
241
+ end
242
+ resource = invocable_resource(props, context, method)
249
243
  script_content = ps_script_content(resource)
250
244
  context.debug("Script:\n #{redact_secrets(script_content)}")
251
- output = ps_manager.execute(script_content)[:stdout]
252
- context.err('Nothing returned') if output.nil?
245
+ output = ps_manager.execute(remove_secret_identifiers(script_content))[:stdout]
246
+ if output.nil?
247
+ context.err('Nothing returned')
248
+ return nil
249
+ end
253
250
 
254
- data = JSON.parse(output)
251
+ begin
252
+ data = JSON.parse(output)
253
+ rescue => e
254
+ context.err(e)
255
+ return nil
256
+ end
255
257
  context.debug("raw data received: #{data.inspect}")
258
+
256
259
  error = data['errormessage']
257
- unless error.nil?
260
+ unless error.nil? || error.empty?
258
261
  # NB: We should have a way to stop processing this resource *now* without blowing up the whole Puppet run
259
262
  # Raising an error stops processing but blows things up while context.err alerts but continues to process
260
263
  if error =~ /Logon failure: the user has not been granted the requested logon type at this computer/
@@ -269,10 +272,47 @@ class Puppet::Provider::DscBaseProvider
269
272
  # Either way, something went wrong and we didn't get back a good result, so return nil
270
273
  return nil
271
274
  end
275
+ data
276
+ end
277
+
278
+ # Determine if the DSC Resource is in the desired state, invoking the `Test` method unless it's
279
+ # already been run for the resource, in which case reuse the result instead of checking for each
280
+ # property. This behavior is only triggered if the validation_mode is set to resource; by default
281
+ # it is set to property and uses the default property comparison logic in Puppet::Property.
282
+ #
283
+ # @param context [Object] the Puppet runtime context to operate in and send feedback to
284
+ # @param name [String] the name of the resource being tested
285
+ # @param is_hash [Hash] the current state of the resource on the system
286
+ # @param should_hash [Hash] the desired state of the resource per the manifest
287
+ # @return [Boolean, Void] returns true/false if the resource is/isn't in the desired state and
288
+ # the validation mode is set to resource, otherwise nil.
289
+ def insync?(context, name, _property_name, _is_hash, should_hash)
290
+ return nil if should_hash[:validation_mode] != 'resource'
291
+
292
+ prior_result = fetch_cached_hashes(@@cached_test_results, [name])
293
+ prior_result.empty? ? invoke_test_method(context, name, should_hash) : prior_result.first[:in_desired_state]
294
+ end
295
+
296
+ # Invokes the `Get` method, passing the name_hash as the properties to use with `Invoke-DscResource`
297
+ # The PowerShell script returns a JSON representation of the DSC Resource's CIM Instance munged as
298
+ # best it can be for Ruby. Once that JSON is parsed into a hash this method further munges it to
299
+ # fit the expected property definitions. Finally, it returns the object for the Resource API to
300
+ # compare against and determine what future actions, if any, are needed.
301
+ #
302
+ # @param context [Object] the Puppet runtime context to operate in and send feedback to
303
+ # @param name_hash [Hash] the hash of namevars to be passed as properties to `Invoke-DscResource`
304
+ # @return [Hash] returns a hash representing the DSC resource munged to the representation the Puppet Type expects
305
+ def invoke_get_method(context, name_hash)
306
+ context.debug("retrieving #{name_hash.inspect}")
307
+
308
+ query_props = name_hash.select { |k, v| mandatory_get_attributes(context).include?(k) || (k == :dsc_psdscrunascredential && !v.nil?) }
309
+ data = invoke_dsc_resource(context, name_hash, query_props, 'get')
310
+ return nil if data.nil?
311
+
272
312
  # DSC gives back information we don't care about; filter down to only
273
313
  # those properties exposed in the type definition.
274
314
  valid_attributes = context.type.attributes.keys.collect(&:to_s)
275
- parameters = context.type.attributes.select { |_name, properties| [properties[:behaviour]].collect.include?(:parameter) }.keys.collect(&:to_s)
315
+ parameters = parameter_attributes(context).collect(&:to_s)
276
316
  data.select! { |key, _value| valid_attributes.include?("dsc_#{key.downcase}") }
277
317
  data.reject! { |key, _value| parameters.include?("dsc_#{key.downcase}") }
278
318
  # Canonicalize the results to match the type definition representation;
@@ -281,14 +321,26 @@ class Puppet::Provider::DscBaseProvider
281
321
  data.keys.each do |key| # rubocop:disable Style/HashEachMethods
282
322
  type_key = "dsc_#{key.downcase}".to_sym
283
323
  data[type_key] = data.delete(key)
284
- camelcase_hash_keys!(data[type_key]) if data[type_key].is_a?(Enumerable)
324
+
325
+ # Special handling for CIM Instances
326
+ if data[type_key].is_a?(Enumerable)
327
+ downcase_hash_keys!(data[type_key])
328
+ munge_cim_instances!(data[type_key])
329
+ end
330
+
285
331
  # Convert DateTime back to appropriate type
286
- data[type_key] = Puppet::Pops::Time::Timestamp.parse(data[type_key]) if context.type.attributes[type_key][:mof_type] =~ /DateTime/i
332
+ if context.type.attributes[type_key][:mof_type] =~ /DateTime/i && !data[type_key].nil?
333
+ data[type_key] = begin
334
+ Puppet::Pops::Time::Timestamp.parse(data[type_key]) if context.type.attributes[type_key][:mof_type] =~ /DateTime/i && !data[type_key].nil?
335
+ rescue ArgumentError, TypeError => e
336
+ # Catch any failures in the parse, output them to the context and then return nil
337
+ context.err("Value returned for DateTime (#{data[type_key].inspect}) failed to parse: #{e}")
338
+ nil
339
+ end
340
+ end
287
341
  # PowerShell does not distinguish between a return of empty array/string
288
342
  # and null but Puppet does; revert to those values if specified.
289
- if data[type_key].nil? && query_props.keys.include?(type_key) && query_props[type_key].is_a?(Array)
290
- data[type_key] = query_props[type_key].empty? ? query_props[type_key] : []
291
- end
343
+ data[type_key] = [] if data[type_key].nil? && query_props.keys.include?(type_key) && query_props[type_key].is_a?(Array)
292
344
  end
293
345
  # If a resource is found, it's present, so refill this Puppet-only key
294
346
  data.merge!({ name: name_hash[:name] })
@@ -302,6 +354,9 @@ class Puppet::Provider::DscBaseProvider
302
354
  # declaration is for an absent resource and the resource is actually absent
303
355
  data.reject! { |_k, v| v.nil? } if data[:dsc_ensure] == 'Absent' && name_hash[:dsc_ensure] == 'Absent' && !name_hash_has_nil_keys
304
356
 
357
+ # Sort the return for order-insensitive nested enumerable comparison:
358
+ data = recursively_sort(data)
359
+
305
360
  # Cache the query to prevent a second lookup
306
361
  @@cached_query_results << data.dup if fetch_cached_hashes(@@cached_query_results, [data]).empty?
307
362
  context.debug("Returned to Puppet as #{data}")
@@ -318,24 +373,36 @@ class Puppet::Provider::DscBaseProvider
318
373
  def invoke_set_method(context, name, should)
319
374
  context.debug("Invoking Set Method for '#{name}' with #{should.inspect}")
320
375
 
321
- # Do not bother running if the logon credentials won't work
322
- return nil if !should[:dsc_psdscrunascredential].nil? && logon_failed_already?(should[:dsc_psdscrunascredential])
323
-
324
376
  apply_props = should.select { |k, _v| k.to_s =~ /^dsc_/ }
325
- resource = should_to_resource(apply_props, context, 'set')
326
- script_content = ps_script_content(resource)
327
- context.debug("Script:\n #{redact_secrets(script_content)}")
328
-
329
- output = ps_manager.execute(script_content)[:stdout]
330
- context.err('Nothing returned') if output.nil?
331
-
332
- data = JSON.parse(output)
333
- context.debug(data)
377
+ invoke_dsc_resource(context, should, apply_props, 'set')
334
378
 
335
- context.err(data['errormessage']) unless data['errormessage'].empty?
336
379
  # TODO: Implement this functionality for notifying a DSC reboot?
337
380
  # notify_reboot_pending if data['rebootrequired'] == true
338
- data
381
+ end
382
+
383
+ # Invokes the `Test` method, passing the should hash as the properties to use with `Invoke-DscResource`
384
+ # The PowerShell script returns a JSON hash with key-value pairs indicating whether or not the resource
385
+ # is in the desired state and any error messages captured.
386
+ #
387
+ # @param context [Object] the Puppet runtime context to operate in and send feedback to
388
+ # @param should [Hash] the desired state represented definition to pass as properties to Invoke-DscResource
389
+ # @return [Boolean] returns true if the resource is in the desired state, otherwise false
390
+ def invoke_test_method(context, name, should)
391
+ context.debug("Relying on DSC Test method for validating if '#{name}' is in the desired state")
392
+ context.debug("Invoking Test Method for '#{name}' with #{should.inspect}")
393
+
394
+ test_props = should.select { |k, _v| k.to_s =~ /^dsc_/ }
395
+ data = invoke_dsc_resource(context, name, test_props, 'test')
396
+ # Something went wrong with Invoke-DscResource; fall back on property state comparisons
397
+ return nil if data.nil?
398
+
399
+ in_desired_state = data['indesiredstate']
400
+ @@cached_test_results << name.merge({ in_desired_state: in_desired_state })
401
+
402
+ return in_desired_state if in_desired_state
403
+
404
+ change_log = 'DSC reported that this resource is not in the desired state; treating all properties as out-of-sync'
405
+ [in_desired_state, change_log]
339
406
  end
340
407
 
341
408
  # Converts a Puppet resource hash into a hash with the information needed to call Invoke-DscResource,
@@ -346,10 +413,10 @@ class Puppet::Provider::DscBaseProvider
346
413
  # @param context [Object] the Puppet runtime context to operate in and send feedback to
347
414
  # @param dsc_invoke_method [String] the method to pass to Invoke-DscResource: get, set, or test
348
415
  # @return [Hash] a hash with the information needed to run `Invoke-DscResource`
349
- def should_to_resource(should, context, dsc_invoke_method)
416
+ def invocable_resource(should, context, dsc_invoke_method)
350
417
  resource = {}
351
418
  resource[:parameters] = {}
352
- %i[name dscmeta_resource_friendly_name dscmeta_resource_name dscmeta_module_name dscmeta_module_version].each do |k|
419
+ %i[name dscmeta_resource_friendly_name dscmeta_resource_name dscmeta_resource_implementation dscmeta_module_name dscmeta_module_version].each do |k|
353
420
  resource[k] = context.type.definition[k]
354
421
  end
355
422
  should.each do |k, v|
@@ -365,6 +432,15 @@ class Puppet::Provider::DscBaseProvider
365
432
  end
366
433
  resource[:dsc_invoke_method] = dsc_invoke_method
367
434
 
435
+ resource[:vendored_modules_path] = vendored_modules_path(resource[:dscmeta_module_name])
436
+
437
+ resource[:attributes] = nil
438
+
439
+ context.debug("invocable_resource: #{resource.inspect}")
440
+ resource
441
+ end
442
+
443
+ def vendored_modules_path(module_name)
368
444
  # Because Puppet adds all of the modules to the LOAD_PATH we can be sure that the appropriate module lives here during an apply;
369
445
  # PROBLEM: This currently uses the downcased name, we need to capture the module name in the metadata I think.
370
446
  # During a Puppet agent run, the code lives in the cache so we can use the file expansion to discover the correct folder.
@@ -372,36 +448,41 @@ class Puppet::Provider::DscBaseProvider
372
448
  # path to allow multiple modules to with shared dsc_resources to be installed side by side
373
449
  # The old vendored_modules_path: puppet_x/dsc_resources
374
450
  # The new vendored_modules_path: puppet_x/<module_name>/dsc_resources
375
- root_module_path = $LOAD_PATH.select { |path| path.match?(%r{#{puppetize_name(resource[:dscmeta_module_name])}/lib}) }.first
376
- resource[:vendored_modules_path] = if root_module_path.nil?
377
- File.expand_path(Pathname.new(__FILE__).dirname + '../../../' + "puppet_x/#{puppetize_name(resource[:dscmeta_module_name])}/dsc_resources") # rubocop:disable Style/StringConcatenation
378
- else
379
- File.expand_path("#{root_module_path}/puppet_x/#{puppetize_name(resource[:dscmeta_module_name])}/dsc_resources")
380
- end
451
+ root_module_path = load_path.select { |path| path.match?(%r{#{puppetize_name(module_name)}/lib}) }.first
452
+ vendored_path = if root_module_path.nil?
453
+ File.expand_path(Pathname.new(__FILE__).dirname + '../../../' + "puppet_x/#{puppetize_name(module_name)}/dsc_resources") # rubocop:disable Style/StringConcatenation
454
+ else
455
+ File.expand_path("#{root_module_path}/puppet_x/#{puppetize_name(module_name)}/dsc_resources")
456
+ end
381
457
 
382
458
  # Check for the old vendored_modules_path second - if there is a mix of modules with the old and new pathing,
383
459
  # checking for this first will always work and so the more specific search will never run.
384
- unless File.exist? resource[:vendored_modules_path]
385
- resource[:vendored_modules_path] = if root_module_path.nil?
386
- File.expand_path(Pathname.new(__FILE__).dirname + '../../../' + 'puppet_x/dsc_resources') # rubocop:disable Style/StringConcatenation
387
- else
388
- File.expand_path("#{root_module_path}/puppet_x/dsc_resources")
389
- end
460
+ unless File.exist? vendored_path
461
+ vendored_path = if root_module_path.nil?
462
+ File.expand_path(Pathname.new(__FILE__).dirname + '../../../' + 'puppet_x/dsc_resources') # rubocop:disable Style/StringConcatenation
463
+ else
464
+ File.expand_path("#{root_module_path}/puppet_x/dsc_resources")
465
+ end
390
466
  end
391
467
 
392
468
  # A warning is thrown if the something went wrong and the file was not created
393
- raise "Unable to find expected vendored DSC Resource #{resource[:vendored_modules_path]}" unless File.exist? resource[:vendored_modules_path]
469
+ raise "Unable to find expected vendored DSC Resource #{vendored_path}" unless File.exist? vendored_path
394
470
 
395
- resource[:attributes] = nil
471
+ vendored_path
472
+ end
396
473
 
397
- context.debug("should_to_resource: #{resource.inspect}")
398
- resource
474
+ # Return the ruby $LOAD_PATH variable; this method exists to make testing vendored
475
+ # resource path discovery easier.
476
+ #
477
+ # @return [Array] The absolute file paths to available/known ruby code paths
478
+ def load_path
479
+ $LOAD_PATH
399
480
  end
400
481
 
401
482
  # Return a String containing a puppetized name. A puppetized name is a string that only
402
483
  # includes lowercase letters, digits, underscores and cannot start with a digit.
403
484
  #
404
- # @return [String] with a puppeized module name
485
+ # @return [String] with a puppetized module name
405
486
  def puppetize_name(name)
406
487
  # Puppet module names must be lower case
407
488
  name = name.downcase
@@ -444,20 +525,27 @@ class Puppet::Provider::DscBaseProvider
444
525
  end
445
526
  end
446
527
 
447
- # Recursively transforms any enumerable, camelCasing any hash keys it finds
528
+ # Recursively transforms any enumerable, downcasing any hash keys it finds, changing the passed enumerable.
448
529
  #
449
530
  # @param enumerable [Enumerable] a string, array, hash, or other object to attempt to recursively downcase
450
- # @return [Enumerable] returns the input object with hash keys recursively camelCased
451
- def camelcase_hash_keys!(enumerable)
531
+ def downcase_hash_keys!(enumerable)
452
532
  if enumerable.is_a?(Hash)
453
533
  enumerable.keys.each do |key| # rubocop:disable Style/HashEachMethods
454
- name = key.dup
455
- name[0] = name[0].downcase
534
+ name = key.dup.downcase
456
535
  enumerable[name] = enumerable.delete(key)
457
- camelcase_hash_keys!(enumerable[name]) if enumerable[name].is_a?(Enumerable)
536
+ downcase_hash_keys!(enumerable[name]) if enumerable[name].is_a?(Enumerable)
458
537
  end
459
538
  else
460
- enumerable.each { |item| camelcase_hash_keys!(item) if item.is_a?(Enumerable) }
539
+ enumerable.each { |item| downcase_hash_keys!(item) if item.is_a?(Enumerable) }
540
+ end
541
+ end
542
+
543
+ def munge_cim_instances!(enumerable)
544
+ if enumerable.is_a?(Hash)
545
+ # Delete the cim_instance_type key from a top-level CIM Instance **only**
546
+ _ = enumerable.delete('cim_instance_type')
547
+ else
548
+ enumerable.each { |item| munge_cim_instances!(item) if item.is_a?(Enumerable) }
461
549
  end
462
550
  end
463
551
 
@@ -482,6 +570,34 @@ class Puppet::Provider::DscBaseProvider
482
570
  end
483
571
  end
484
572
 
573
+ # Recursively sorts any object to enable order-insensitive comparisons
574
+ #
575
+ # @param object [Object] an array, hash, or other object to attempt to recursively downcase
576
+ # @return [Object] returns the input object recursively downcased
577
+ def recursively_sort(object)
578
+ case object
579
+ when Array
580
+ object.map { |item| recursively_sort(item) }.sort_by(&:to_s)
581
+ when Hash
582
+ transformed = {}
583
+ object.sort.to_h.each do |key, value|
584
+ transformed[key] = recursively_sort(value)
585
+ end
586
+ transformed
587
+ else
588
+ object
589
+ end
590
+ end
591
+
592
+ # Check equality, sort if necessary
593
+ #
594
+ # @param value1 [object] a string, array, hash, or other object to sort and compare to value2
595
+ # @param value2 [object] a string, array, hash, or other object to sort and compare to value1
596
+ # @return [bool] returns equality
597
+ def same?(value1, value2)
598
+ recursively_sort(value2) == recursively_sort(value1)
599
+ end
600
+
485
601
  # Parses the DSC resource type definition to retrieve the names of any attributes which are specified as mandatory for get operations
486
602
  #
487
603
  # @param context [Object] the Puppet runtime context to operate in and send feedback to
@@ -514,6 +630,16 @@ class Puppet::Provider::DscBaseProvider
514
630
  context.type.attributes.select { |_name, properties| properties[:behaviour] == :parameter }.keys
515
631
  end
516
632
 
633
+ # Parses the DSC resource type definition to retrieve the names of any attributes which are specified as enums
634
+ # Note that for complex types, especially those that have nested CIM instances, this will return for any data
635
+ # type which *includes* an Enum, not just for simple `Enum[]` or `Optional[Enum[]]` data types.
636
+ #
637
+ # @param context [Object] the Puppet runtime context to operate in and send feedback to
638
+ # @return [Array] returns an array of attribute names as symbols which are enums
639
+ def enum_attributes(context)
640
+ context.type.attributes.select { |_name, properties| properties[:type].match(/Enum\[/) }.keys
641
+ end
642
+
517
643
  # Look through a fully formatted string, replacing all instances where a value matches the formatted properties
518
644
  # of an instantiated variable with references to the variable instead. This allows us to pass complex and nested
519
645
  # CIM instances to the Invoke-DscResource parameter hash without constructing them *in* the hash.
@@ -529,7 +655,28 @@ class Puppet::Provider::DscBaseProvider
529
655
  modified_string
530
656
  end
531
657
 
532
- # Parses a resource definition (as from `should_to_resource`) for any properties which are PowerShell
658
+ # Parses a resource definition (as from `invocable_resource`) and, if the resource is implemented
659
+ # as a PowerShell class, ensures the System environment variable for PSModulePath is munged to
660
+ # include the vendored PowerShell modules. Due to a bug in PSDesiredStateConfiguration, class-based
661
+ # DSC Resources cannot be called via Invoke-DscResource by path, only by module name, *and* the
662
+ # module must be discoverable in the system-level PSModulePath. The postscript for invocation has
663
+ # logic to reset the system PSModulePath as stored in the script lines returned by this method.
664
+ #
665
+ # @param resource [Hash] a hash with the information needed to run `Invoke-DscResource`
666
+ # @return [String] A multi-line string which sets the PSModulePath at the system level
667
+ def munge_psmodulepath(resource)
668
+ return unless resource[:dscmeta_resource_implementation] == 'Class'
669
+
670
+ vendor_path = resource[:vendored_modules_path].gsub('/', '\\')
671
+ <<~MUNGE_PSMODULEPATH.strip
672
+ $UnmungedPSModulePath = [System.Environment]::GetEnvironmentVariable('PSModulePath','machine')
673
+ $MungedPSModulePath = $env:PSModulePath + ';#{vendor_path}'
674
+ [System.Environment]::SetEnvironmentVariable('PSModulePath', $MungedPSModulePath, [System.EnvironmentVariableTarget]::Machine)
675
+ $env:PSModulePath = [System.Environment]::GetEnvironmentVariable('PSModulePath','machine')
676
+ MUNGE_PSMODULEPATH
677
+ end
678
+
679
+ # Parses a resource definition (as from `invocable_resource`) for any properties which are PowerShell
533
680
  # Credentials. As these values need to be serialized into PSCredential objects, return an array of
534
681
  # PowerShell lines, each of which instantiates a variable which holds the value as a PSCredential.
535
682
  # These credential variables can then be simply assigned in the parameter hash where needed.
@@ -560,10 +707,10 @@ class Puppet::Provider::DscBaseProvider
560
707
  # @param credential_hash [Hash] the Properties which define the PSCredential Object
561
708
  # @return [String] A line of PowerShell which defines the PSCredential object and stores it to a variable
562
709
  def format_pscredential(variable_name, credential_hash)
563
- "$#{variable_name} = New-PSCredential -User #{credential_hash['user']} -Password '#{credential_hash['password']}' # PuppetSensitive"
710
+ "$#{variable_name} = New-PSCredential -User #{credential_hash['user']} -Password '#{credential_hash['password']}#{SECRET_POSTFIX}'"
564
711
  end
565
712
 
566
- # Parses a resource definition (as from `should_to_resource`) for any properties which are CIM instances
713
+ # Parses a resource definition (as from `invocable_resource`) for any properties which are CIM instances
567
714
  # whether at the top level or nested inside of other CIM instances, and, where they are discovered, adds
568
715
  # those objects to the instantiated_variables hash as well as returning a line of PowerShell code which
569
716
  # will create the CIM object and store it in a variable. This then allows the CIM instances to be assigned
@@ -644,7 +791,7 @@ class Puppet::Provider::DscBaseProvider
644
791
  interpolate_variables(definition)
645
792
  end
646
793
 
647
- # Munge a resource definition (as from `should_to_resource`) into valid PowerShell which represents
794
+ # Munge a resource definition (as from `invocable_resource`) into valid PowerShell which represents
648
795
  # the `InvokeParams` hash which will be splatted to `Invoke-DscResource`, interpolating all previously
649
796
  # defined variables into the hash.
650
797
  #
@@ -658,7 +805,11 @@ class Puppet::Provider::DscBaseProvider
658
805
  }
659
806
  if resource.key?(:dscmeta_module_version)
660
807
  params[:ModuleName] = {}
661
- params[:ModuleName][:ModuleName] = "#{resource[:vendored_modules_path]}/#{resource[:dscmeta_module_name]}/#{resource[:dscmeta_module_name]}.psd1"
808
+ params[:ModuleName][:ModuleName] = if resource[:dscmeta_resource_implementation] == 'Class'
809
+ resource[:dscmeta_module_name]
810
+ else
811
+ "#{resource[:vendored_modules_path]}/#{resource[:dscmeta_module_name]}/#{resource[:dscmeta_module_name]}.psd1"
812
+ end
662
813
  params[:ModuleName][:RequiredVersion] = resource[:dscmeta_module_version]
663
814
  else
664
815
  params[:ModuleName] = resource[:dscmeta_module_name]
@@ -700,7 +851,7 @@ class Puppet::Provider::DscBaseProvider
700
851
  params_block
701
852
  end
702
853
 
703
- # Given a resource definition (as from `should_to_resource`), return a PowerShell script which has
854
+ # Given a resource definition (as from `invocable_resource`), return a PowerShell script which has
704
855
  # all of the appropriate function and variable definitions, which will call Invoke-DscResource, and
705
856
  # will correct munge the results for returning to Puppet as a JSON object.
706
857
  #
@@ -715,13 +866,14 @@ class Puppet::Provider::DscBaseProvider
715
866
  # The postscript defines the invocation error and result handling; expects `$InvokeParams` to be defined
716
867
  postscript = File.new("#{template_path}/invoke_dsc_resource_postscript.ps1").read
717
868
  # The blocks define the variables to define for the postscript.
869
+ module_path_block = munge_psmodulepath(resource)
718
870
  credential_block = prepare_credentials(resource)
719
871
  cim_instances_block = prepare_cim_instances(resource)
720
872
  parameters_block = invoke_params(resource)
721
873
  # clean them out of the temporary cache now that they're not needed; failure to do so can goof up future executions in this run
722
874
  clear_instantiated_variables!
723
875
 
724
- [functions, preamble, credential_block, cim_instances_block, parameters_block, postscript].join("\n")
876
+ [functions, preamble, module_path_block, credential_block, cim_instances_block, parameters_block, postscript].join("\n")
725
877
  end
726
878
 
727
879
  # Convert a Puppet/Ruby value into a PowerShell representation. Requires some slight additional
@@ -735,19 +887,18 @@ class Puppet::Provider::DscBaseProvider
735
887
  rescue RuntimeError => e
736
888
  raise unless e.message =~ /Sensitive \[value redacted\]/
737
889
 
738
- string = Pwsh::Util.format_powershell_value(unwrap(value))
739
- string.gsub(/#PuppetSensitive'}/, "'} # PuppetSensitive")
890
+ Pwsh::Util.format_powershell_value(unwrap(value))
740
891
  end
741
892
 
742
- # Unwrap sensitive strings for formatting, even inside an enumerable, appending '#PuppetSensitive'
743
- # to the end of the string in preparation for gsub cleanup.
893
+ # Unwrap sensitive strings for formatting, even inside an enumerable, appending the
894
+ # the secret postfix to the end of the string in preparation for gsub cleanup.
744
895
  #
745
896
  # @param value [Object] The object to unwrap sensitive data inside of
746
897
  # @return [Object] The object with any sensitive strings unwrapped and annotated
747
898
  def unwrap(value)
748
899
  case value
749
900
  when Puppet::Pops::Types::PSensitiveType::Sensitive
750
- "#{value.unwrap}#PuppetSensitive"
901
+ "#{value.unwrap}#{SECRET_POSTFIX}"
751
902
  when Hash
752
903
  unwrapped = {}
753
904
  value.each do |k, v|
@@ -773,6 +924,47 @@ class Puppet::Provider::DscBaseProvider
773
924
  text.gsub("'", "''")
774
925
  end
775
926
 
927
+ # In order to avoid having to update the string that indicates when a value came from a sensitive
928
+ # string in multiple places, use a constant to indicate what the text of the secret identifier
929
+ # should be. This is used to write, identify, and redact secrets between PowerShell & Puppet.
930
+ SECRET_POSTFIX = '#PuppetSensitive'
931
+
932
+ # With multiple methods which need to discover secrets it is necessary to keep a single regex
933
+ # which can discover them. This will lazily match everything in a single-quoted string which
934
+ # ends with the secret postfix id and mark the actual contents of the string as the secret.
935
+ SECRET_DATA_REGEX = /'(?<secret>[^']+)+?#{Regexp.quote(SECRET_POSTFIX)}'/.freeze
936
+
937
+ # Strings containing sensitive data have a secrets postfix. These strings cannot be passed
938
+ # directly either to debug streams or to PowerShell and must be handled; this method contains
939
+ # the shared logic for parsing text for secrets and substituting values for them.
940
+ #
941
+ # @param text [String] the text to parse and handle for secrets
942
+ # @param replacement [String] the value to pass to gsub to replace secrets with
943
+ # @param error_message [String] the error message to raise instead of leaking secrets
944
+ # @return [String] the modified text
945
+ def handle_secrets(text, replacement, error_message)
946
+ # Every secret unwrapped in this module will unwrap as "'secret#{SECRET_POSTFIX}'"
947
+ # Currently, no known resources specify a SecureString instead of a PSCredential object.
948
+ return text unless text.match(/#{Regexp.quote(SECRET_POSTFIX)}/)
949
+
950
+ # In order to reduce time-to-parse, look at each line individually and *only* attempt
951
+ # to substitute if a naive match for the secret postfix is found on the line.
952
+ modified_text = text.split("\n").map do |line|
953
+ if line.match(/#{Regexp.quote(SECRET_POSTFIX)}/)
954
+ line.gsub(SECRET_DATA_REGEX, replacement)
955
+ else
956
+ line
957
+ end
958
+ end
959
+
960
+ modified_text = modified_text.join("\n")
961
+
962
+ # Something has gone wrong, error loudly
963
+ raise error_message if modified_text =~ /#{Regexp.quote(SECRET_POSTFIX)}/
964
+
965
+ modified_text
966
+ end
967
+
776
968
  # While Puppet is aware of Sensitive data types, the PowerShell script is not
777
969
  # and so for debugging purposes must be redacted before being sent to debug
778
970
  # output but must *not* be redacted when sent to the PowerShell code manager.
@@ -780,15 +972,17 @@ class Puppet::Provider::DscBaseProvider
780
972
  # @param text [String] the text to redact
781
973
  # @return [String] the redacted text
782
974
  def redact_secrets(text)
783
- # Every secret unwrapped in this module will unwrap as "'secret' # PuppetSensitive" and, currently,
784
- # no known resources specify a SecureString instead of a PSCredential object. We therefore only
785
- # need to redact strings which look like password declarations.
786
- modified_text = text.gsub(/(?<=-Password )'.+' # PuppetSensitive/, "'#<Sensitive [value redacted]>'")
787
- if modified_text =~ /'.+' # PuppetSensitive/
788
- # Something has gone wrong, error loudly?
789
- else
790
- modified_text
791
- end
975
+ handle_secrets(text, "'#<Sensitive [value redacted]>'", "Unredacted sensitive data would've been leaked")
976
+ end
977
+
978
+ # While Puppet is aware of Sensitive data types, the PowerShell script is not
979
+ # and so the helper-id for sensitive data *must* be removed before sending to
980
+ # the PowerShell code manager.
981
+ #
982
+ # @param text [String] the text to strip of secret data identifiers
983
+ # @return [String] the modified text to pass to the PowerShell code manager
984
+ def remove_secret_identifiers(text)
985
+ handle_secrets(text, "'\\k<secret>'", 'Unable to properly format text for PowerShell with sensitive data')
792
986
  end
793
987
 
794
988
  # Instantiate a PowerShell manager via the ruby-pwsh library and use it to invoke PowerShell.