ruby-pwsh 0.7.2 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 01ea31b0b9ccabe461c1e51ba916f674e93658dcc82ce618e23066ba4d3273bb
4
- data.tar.gz: 407cae60f748c60584668f52f7354e59a3c1d8012ec0494c017f7e9da49e6cee
3
+ metadata.gz: 350652f7e3803a522f6ad56512a75778277bef24bb3963fa9713522517f511cc
4
+ data.tar.gz: '02500121972f8b43463d6e3db9d37ab7324bcecb521b0fc482a9540d1c7bb88b'
5
5
  SHA512:
6
- metadata.gz: 209ac4aafdc2717285ad637217e99e6a469b286e10fcb1d0ba6b13759f04b2f94f0ce69fd813984f581eb27a35ba8cb6ea41eb50ecb7a2c2f4cfffa5469a23f4
7
- data.tar.gz: fc99b84247299879dffb1c41b89beaa4a23eddfd3d61ad605d3aa8568f98fc1acf2f072bc264e17b9d91df3be3f59ffffa7fe9981270f61fc0adcdf0048e10ab
6
+ metadata.gz: 5f3e181119fc56713f3f326081ec0f84540308f8918425afc7b10e89baa6813456b3e369b98b7c7ae255484ba425dee0e00239cce5d98683b013d8971ad5d27a
7
+ data.tar.gz: 0eaa28aeb753b121d46cd83127ff0dffdaa4cbf8bb0463b07b069fea0a339461a71d1746232e9cc3ad49413daa07b315c0a11b55eb85700f54bcda5667606db4
@@ -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,7 @@ 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/
data/CHANGELOG.md CHANGED
@@ -2,6 +2,61 @@
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.10.0](https://github.com/puppetlabs/ruby-pwsh/tree/0.10.0) (2021-07-01)
6
+
7
+ [Full Changelog](https://github.com/puppetlabs/ruby-pwsh/compare/0.9.0...0.10.0)
8
+
9
+ ### Added
10
+
11
+ - \(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))
12
+
13
+ ## [0.9.0](https://github.com/puppetlabs/ruby-pwsh/tree/0.9.0) (2021-06-28)
14
+
15
+ [Full Changelog](https://github.com/puppetlabs/ruby-pwsh/compare/0.8.0...0.9.0)
16
+
17
+ ### Added
18
+
19
+ - \(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))
20
+ - \(GH-145\) Improve DSC secrets redaction [\#150](https://github.com/puppetlabs/ruby-pwsh/pull/150) ([michaeltlombardi](https://github.com/michaeltlombardi))
21
+ - \(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))
22
+ - \(MAINT\) Clarify supported platforms [\#113](https://github.com/puppetlabs/ruby-pwsh/pull/113) ([michaeltlombardi](https://github.com/michaeltlombardi))
23
+
24
+ ### Fixed
25
+
26
+ - \(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))
27
+ - \(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))
28
+ - \(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))
29
+ - \(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))
30
+ - \(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))
31
+ - \(GH-144\) Enable order-insensitive comparisons for DSC [\#151](https://github.com/puppetlabs/ruby-pwsh/pull/151) ([michaeltlombardi](https://github.com/michaeltlombardi))
32
+ - \(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))
33
+ - \(GH-127\) Canonicalize enums correctly [\#131](https://github.com/puppetlabs/ruby-pwsh/pull/131) ([michaeltlombardi](https://github.com/michaeltlombardi))
34
+ - \(GH-125\) Fix dsc provider canonicalization for absent resources [\#129](https://github.com/puppetlabs/ruby-pwsh/pull/129) ([michaeltlombardi](https://github.com/michaeltlombardi))
35
+ - \(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))
36
+ - \(MODULES-11026\) Ensure the PowerShell manager works with v7 [\#122](https://github.com/puppetlabs/ruby-pwsh/pull/122) ([n3snah](https://github.com/n3snah))
37
+ - \(Maint\) Ensure canonicalize correctly compares sorted hashes [\#118](https://github.com/puppetlabs/ruby-pwsh/pull/118) ([Hvid](https://github.com/Hvid))
38
+
39
+ ## [0.8.0](https://github.com/puppetlabs/ruby-pwsh/tree/0.8.0) (2021-03-01)
40
+
41
+ [Full Changelog](https://github.com/puppetlabs/ruby-pwsh/compare/0.7.4...0.8.0)
42
+
43
+ ## [0.7.4](https://github.com/puppetlabs/ruby-pwsh/tree/0.7.4) (2021-02-12)
44
+
45
+ [Full Changelog](https://github.com/puppetlabs/ruby-pwsh/compare/0.7.3...0.7.4)
46
+
47
+ ### Fixed
48
+
49
+ - \(GH-105\) Ensure set runs on ambiguous ensure states [\#108](https://github.com/puppetlabs/ruby-pwsh/pull/108) ([michaeltlombardi](https://github.com/michaeltlombardi))
50
+ - \(GH-105\) Ensure canonicalized\_cache check validates against namevar [\#107](https://github.com/puppetlabs/ruby-pwsh/pull/107) ([michaeltlombardi](https://github.com/michaeltlombardi))
51
+
52
+ ## [0.7.3](https://github.com/puppetlabs/ruby-pwsh/tree/0.7.3) (2021-02-03)
53
+
54
+ [Full Changelog](https://github.com/puppetlabs/ruby-pwsh/compare/0.7.2...0.7.3)
55
+
56
+ ### Fixed
57
+
58
+ - \(MAINT\) Place nil check when assigning is\_same [\#101](https://github.com/puppetlabs/ruby-pwsh/pull/101) ([bwilcox](https://github.com/bwilcox))
59
+
5
60
  ## [0.7.2](https://github.com/puppetlabs/ruby-pwsh/tree/0.7.2) (2021-02-03)
6
61
 
7
62
  [Full Changelog](https://github.com/puppetlabs/ruby-pwsh/compare/0.7.1...0.7.2)
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,37 @@ 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
+ namespace :dsc do
103
+ namespace :acceptance do
104
+ desc 'Prep for running DSC acceptance tests'
105
+ task :spec_prep do
106
+ # Create the modules fixture folder, if needed
107
+ modules_folder = File.expand_path('spec/fixtures/modules', File.dirname(__FILE__))
108
+ FileUtils.mkdir_p(modules_folder) unless Dir.exist?(modules_folder)
109
+ # symlink the parent folder to the modules folder for puppet
110
+ File.symlink(File.dirname(__FILE__), File.expand_path('pwshlib', modules_folder))
111
+ # Install each of the required modules for acceptance testing
112
+ # Note: This only works for modules in the dsc namespace on the forge.
113
+ puppetized_dsc_modules = [
114
+ { name: 'powershellget', version: '2.2.5-0-1' },
115
+ { name: 'jeadsc', version: '0.7.2-0-2' } # update to 0.7.2-0-3 on release
116
+ ]
117
+ puppetized_dsc_modules.each do |puppet_module|
118
+ next if Dir.exist?(File.expand_path(puppet_module[:name], modules_folder))
119
+
120
+ install_command = [
121
+ 'bundle exec puppet module install',
122
+ "dsc-#{puppet_module[:name]}",
123
+ "--version #{puppet_module[:version]}",
124
+ '--ignore-dependencies',
125
+ "--target-dir #{modules_folder}"
126
+ ].join(' ')
127
+ run_local_command(install_command)
128
+ end
129
+ end
130
+ RSpec::Core::RakeTask.new(:spec) do |t|
131
+ t.pattern = 'spec/acceptance/dsc/*.rb'
132
+ end
133
+ end
134
+ 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.
@@ -44,31 +49,53 @@ class Puppet::Provider::DscBaseProvider
44
49
  def canonicalize(context, resources)
45
50
  canonicalized_resources = []
46
51
  resources.collect do |r|
47
- if fetch_cached_hashes(@@cached_canonicalized_resource, [r]).empty?
48
- canonicalized = invoke_get_method(context, r)
49
- if canonicalized.nil?
52
+ # During RSAPI refresh runs mandatory parameters are stripped and not available;
53
+ # Instead of checking again and failing, search the cache for a namevar match.
54
+ namevarized_r = r.select { |k, _v| namevar_attributes(context).include?(k) }
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'
50
61
  canonicalized = r.dup
51
62
  @@cached_canonicalized_resource << r.dup
52
63
  else
53
- parameters = r.select { |name, _properties| parameter_attributes(context).include?(name) }
54
- canonicalized.merge!(parameters)
55
- canonicalized[:name] = r[:name]
56
- if r[:dsc_psdscrunascredential].nil?
57
- 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
58
71
  else
59
- canonicalized[:dsc_psdscrunascredential] = r[:dsc_psdscrunascredential]
60
- end
61
- downcased_result = recursively_downcase(canonicalized)
62
- downcased_resource = recursively_downcase(r)
63
- downcased_result.each do |key, value|
64
- is_same = value.is_a?(Enumerable) & !downcased_resource[key] ? downcased_resource[key].sort == value.sort : downcased_resource[key] == value
65
- canonicalized[key] = r[key] unless is_same
66
- 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
67
92
  end
68
- # Cache the actually canonicalized resource separately
69
- @@cached_canonicalized_resource << canonicalized.dup
93
+ # rubocop:enable Metrics/BlockNesting
70
94
  end
71
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.
72
99
  canonicalized = r
73
100
  end
74
101
  canonicalized_resources << canonicalized
@@ -89,7 +116,7 @@ class Puppet::Provider::DscBaseProvider
89
116
  # Relies on the get_simple_filter feature to pass the namevars
90
117
  # as an array containing the namevar parameters as a hash.
91
118
  # This hash is functionally the same as a should hash as
92
- # passed to the should_to_resource method.
119
+ # passed to the invocable_resource method.
93
120
  context.debug('Collecting data from the DSC Resource')
94
121
 
95
122
  # If the resource has already been queried, do not bother querying for it again
@@ -120,27 +147,14 @@ class Puppet::Provider::DscBaseProvider
120
147
  # @param changes [Hash] the hash of whose key is the name_hash and value is the is and should hashes
121
148
  def set(context, changes)
122
149
  changes.each do |name, change|
123
- is = change.key?(:is) ? change[:is] : (get(context, [name]) || []).find { |r| r[:name] == name }
124
- context.type.check_schema(is) unless change.key?(:is)
125
-
150
+ is = change[:is]
126
151
  should = change[:should]
127
152
 
128
- name_hash = if context.type.namevars.length > 1
129
- # pass a name_hash containing the values of all namevars
130
- name_hash = {}
131
- context.type.namevars.each do |namevar|
132
- name_hash[namevar] = change[:should][namevar]
133
- end
134
- name_hash
135
- else
136
- name
137
- 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
138
155
 
139
156
  # for compatibility sake, we use dsc_ensure instead of ensure, so context.type.ensurable? does not work
140
157
  if context.type.attributes.key?(:dsc_ensure)
141
- is = create_absent(:name, name) if is.nil?
142
- should = create_absent(:name, name) if should.nil?
143
-
144
158
  # HACK: If the DSC Resource is ensurable but doesn't report a default value
145
159
  # for ensure, we assume it to be `Present` - this is the most common pattern.
146
160
  should_ensure = should[:dsc_ensure].nil? ? 'Present' : should[:dsc_ensure].to_s
@@ -149,41 +163,31 @@ class Puppet::Provider::DscBaseProvider
149
163
 
150
164
  if is_ensure == 'Absent' && should_ensure == 'Present'
151
165
  context.creating(name) do
152
- create(context, name_hash, should)
166
+ create(context, name, should)
153
167
  end
154
168
  elsif is_ensure == 'Present' && should_ensure == 'Present'
155
169
  context.updating(name) do
156
- update(context, name_hash, should)
170
+ update(context, name, should)
157
171
  end
158
172
  elsif is_ensure == 'Present' && should_ensure == 'Absent'
159
173
  context.deleting(name) do
160
- delete(context, name_hash)
174
+ delete(context, name)
175
+ end
176
+ else
177
+ # In this case we are not sure if the resource is being created/updated/removed
178
+ # as with ensure "latest" or a specific version number, so default to update.
179
+ context.updating(name) do
180
+ update(context, name, should)
161
181
  end
162
182
  end
163
183
  else
164
184
  context.updating(name) do
165
- update(context, name_hash, should)
185
+ update(context, name, should)
166
186
  end
167
187
  end
168
188
  end
169
189
  end
170
190
 
171
- # Creates a hash with the name / name_hash and sets dsc_ensure to absent for comparison
172
- # purposes; this handles cases where the resource isn't found on the node.
173
- #
174
- # @param namevar [Object] the name of the variable being used for the resource name
175
- # @param title [Hash] the hash of namevar properties and their values
176
- # @return [Hash] returns a hash representing the absent state of the resource
177
- def create_absent(namevar, title)
178
- result = if title.is_a? Hash
179
- title.dup
180
- else
181
- { namevar => title }
182
- end
183
- result[:dsc_ensure] = 'Absent'
184
- result
185
- end
186
-
187
191
  # Attempts to set an instance of the DSC resource, invoking the `Set` method and thinly wrapping
188
192
  # the `invoke_set_method` method; whether this method, `update`, or `delete` is called is entirely
189
193
  # up to the Resource API based on the results
@@ -220,32 +224,40 @@ class Puppet::Provider::DscBaseProvider
220
224
  invoke_set_method(context, name, name.merge({ dsc_ensure: 'Absent' }))
221
225
  end
222
226
 
223
- # Invokes the `Get` method, passing the name_hash as the properties to use with `Invoke-DscResource`
224
- # The PowerShell script returns a JSON representation of the DSC Resource's CIM Instance munged as
225
- # best it can be for Ruby. Once that JSON is parsed into a hash this method further munges it to
226
- # fit the expected property definitions. Finally, it returns the object for the Resource API to
227
- # 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.
228
230
  #
229
231
  # @param context [Object] the Puppet runtime context to operate in and send feedback to
230
232
  # @param name_hash [Hash] the hash of namevars to be passed as properties to `Invoke-DscResource`
231
- # @return [Hash] returns a hash representing the DSC resource munged to the representation the Puppet Type expects
232
- def invoke_get_method(context, name_hash)
233
- context.debug("retrieving #{name_hash.inspect}")
234
-
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)
235
237
  # Do not bother running if the logon credentials won't work
236
- return name_hash if !name_hash[:dsc_psdscrunascredential].nil? && logon_failed_already?(name_hash[:dsc_psdscrunascredential])
237
-
238
- query_props = name_hash.select { |k, v| mandatory_get_attributes(context).include?(k) || (k == :dsc_psdscrunascredential && !v.nil?) }
239
- 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)
240
243
  script_content = ps_script_content(resource)
241
244
  context.debug("Script:\n #{redact_secrets(script_content)}")
242
- output = ps_manager.execute(script_content)[:stdout]
243
- 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
244
250
 
245
- data = JSON.parse(output)
251
+ begin
252
+ data = JSON.parse(output)
253
+ rescue => e
254
+ context.err(e)
255
+ return nil
256
+ end
246
257
  context.debug("raw data received: #{data.inspect}")
258
+
247
259
  error = data['errormessage']
248
- unless error.nil?
260
+ unless error.nil? || error.empty?
249
261
  # NB: We should have a way to stop processing this resource *now* without blowing up the whole Puppet run
250
262
  # Raising an error stops processing but blows things up while context.err alerts but continues to process
251
263
  if error =~ /Logon failure: the user has not been granted the requested logon type at this computer/
@@ -260,10 +272,47 @@ class Puppet::Provider::DscBaseProvider
260
272
  # Either way, something went wrong and we didn't get back a good result, so return nil
261
273
  return nil
262
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
+
263
312
  # DSC gives back information we don't care about; filter down to only
264
313
  # those properties exposed in the type definition.
265
314
  valid_attributes = context.type.attributes.keys.collect(&:to_s)
266
- parameters = context.type.attributes.select { |_name, properties| [properties[:behaviour]].collect.include?(:parameter) }.keys.collect(&:to_s)
315
+ parameters = parameter_attributes(context).collect(&:to_s)
267
316
  data.select! { |key, _value| valid_attributes.include?("dsc_#{key.downcase}") }
268
317
  data.reject! { |key, _value| parameters.include?("dsc_#{key.downcase}") }
269
318
  # Canonicalize the results to match the type definition representation;
@@ -272,14 +321,26 @@ class Puppet::Provider::DscBaseProvider
272
321
  data.keys.each do |key| # rubocop:disable Style/HashEachMethods
273
322
  type_key = "dsc_#{key.downcase}".to_sym
274
323
  data[type_key] = data.delete(key)
275
- 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
+
276
331
  # Convert DateTime back to appropriate type
277
- 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
278
341
  # PowerShell does not distinguish between a return of empty array/string
279
342
  # and null but Puppet does; revert to those values if specified.
280
- if data[type_key].nil? && query_props.keys.include?(type_key) && query_props[type_key].is_a?(Array)
281
- data[type_key] = query_props[type_key].empty? ? query_props[type_key] : []
282
- end
343
+ data[type_key] = [] if data[type_key].nil? && query_props.keys.include?(type_key) && query_props[type_key].is_a?(Array)
283
344
  end
284
345
  # If a resource is found, it's present, so refill this Puppet-only key
285
346
  data.merge!({ name: name_hash[:name] })
@@ -293,6 +354,9 @@ class Puppet::Provider::DscBaseProvider
293
354
  # declaration is for an absent resource and the resource is actually absent
294
355
  data.reject! { |_k, v| v.nil? } if data[:dsc_ensure] == 'Absent' && name_hash[:dsc_ensure] == 'Absent' && !name_hash_has_nil_keys
295
356
 
357
+ # Sort the return for order-insensitive nested enumerable comparison:
358
+ data = recursively_sort(data)
359
+
296
360
  # Cache the query to prevent a second lookup
297
361
  @@cached_query_results << data.dup if fetch_cached_hashes(@@cached_query_results, [data]).empty?
298
362
  context.debug("Returned to Puppet as #{data}")
@@ -309,24 +373,36 @@ class Puppet::Provider::DscBaseProvider
309
373
  def invoke_set_method(context, name, should)
310
374
  context.debug("Invoking Set Method for '#{name}' with #{should.inspect}")
311
375
 
312
- # Do not bother running if the logon credentials won't work
313
- return nil if !should[:dsc_psdscrunascredential].nil? && logon_failed_already?(should[:dsc_psdscrunascredential])
314
-
315
376
  apply_props = should.select { |k, _v| k.to_s =~ /^dsc_/ }
316
- resource = should_to_resource(apply_props, context, 'set')
317
- script_content = ps_script_content(resource)
318
- context.debug("Script:\n #{redact_secrets(script_content)}")
377
+ invoke_dsc_resource(context, should, apply_props, 'set')
319
378
 
320
- output = ps_manager.execute(script_content)[:stdout]
321
- context.err('Nothing returned') if output.nil?
322
-
323
- data = JSON.parse(output)
324
- context.debug(data)
325
-
326
- context.err(data['errormessage']) unless data['errormessage'].empty?
327
379
  # TODO: Implement this functionality for notifying a DSC reboot?
328
380
  # notify_reboot_pending if data['rebootrequired'] == true
329
- 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]
330
406
  end
331
407
 
332
408
  # Converts a Puppet resource hash into a hash with the information needed to call Invoke-DscResource,
@@ -337,10 +413,10 @@ class Puppet::Provider::DscBaseProvider
337
413
  # @param context [Object] the Puppet runtime context to operate in and send feedback to
338
414
  # @param dsc_invoke_method [String] the method to pass to Invoke-DscResource: get, set, or test
339
415
  # @return [Hash] a hash with the information needed to run `Invoke-DscResource`
340
- def should_to_resource(should, context, dsc_invoke_method)
416
+ def invocable_resource(should, context, dsc_invoke_method)
341
417
  resource = {}
342
418
  resource[:parameters] = {}
343
- %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|
344
420
  resource[k] = context.type.definition[k]
345
421
  end
346
422
  should.each do |k, v|
@@ -356,6 +432,15 @@ class Puppet::Provider::DscBaseProvider
356
432
  end
357
433
  resource[:dsc_invoke_method] = dsc_invoke_method
358
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)
359
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;
360
445
  # PROBLEM: This currently uses the downcased name, we need to capture the module name in the metadata I think.
361
446
  # During a Puppet agent run, the code lives in the cache so we can use the file expansion to discover the correct folder.
@@ -363,36 +448,41 @@ class Puppet::Provider::DscBaseProvider
363
448
  # path to allow multiple modules to with shared dsc_resources to be installed side by side
364
449
  # The old vendored_modules_path: puppet_x/dsc_resources
365
450
  # The new vendored_modules_path: puppet_x/<module_name>/dsc_resources
366
- root_module_path = $LOAD_PATH.select { |path| path.match?(%r{#{puppetize_name(resource[:dscmeta_module_name])}/lib}) }.first
367
- resource[:vendored_modules_path] = if root_module_path.nil?
368
- File.expand_path(Pathname.new(__FILE__).dirname + '../../../' + "puppet_x/#{puppetize_name(resource[:dscmeta_module_name])}/dsc_resources") # rubocop:disable Style/StringConcatenation
369
- else
370
- File.expand_path("#{root_module_path}/puppet_x/#{puppetize_name(resource[:dscmeta_module_name])}/dsc_resources")
371
- 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
372
457
 
373
458
  # Check for the old vendored_modules_path second - if there is a mix of modules with the old and new pathing,
374
459
  # checking for this first will always work and so the more specific search will never run.
375
- unless File.exist? resource[:vendored_modules_path]
376
- resource[:vendored_modules_path] = if root_module_path.nil?
377
- File.expand_path(Pathname.new(__FILE__).dirname + '../../../' + 'puppet_x/dsc_resources') # rubocop:disable Style/StringConcatenation
378
- else
379
- File.expand_path("#{root_module_path}/puppet_x/dsc_resources")
380
- 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
381
466
  end
382
467
 
383
468
  # A warning is thrown if the something went wrong and the file was not created
384
- 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
385
470
 
386
- resource[:attributes] = nil
471
+ vendored_path
472
+ end
387
473
 
388
- context.debug("should_to_resource: #{resource.inspect}")
389
- 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
390
480
  end
391
481
 
392
482
  # Return a String containing a puppetized name. A puppetized name is a string that only
393
483
  # includes lowercase letters, digits, underscores and cannot start with a digit.
394
484
  #
395
- # @return [String] with a puppeized module name
485
+ # @return [String] with a puppetized module name
396
486
  def puppetize_name(name)
397
487
  # Puppet module names must be lower case
398
488
  name = name.downcase
@@ -435,20 +525,27 @@ class Puppet::Provider::DscBaseProvider
435
525
  end
436
526
  end
437
527
 
438
- # 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.
439
529
  #
440
530
  # @param enumerable [Enumerable] a string, array, hash, or other object to attempt to recursively downcase
441
- # @return [Enumerable] returns the input object with hash keys recursively camelCased
442
- def camelcase_hash_keys!(enumerable)
531
+ def downcase_hash_keys!(enumerable)
443
532
  if enumerable.is_a?(Hash)
444
533
  enumerable.keys.each do |key| # rubocop:disable Style/HashEachMethods
445
- name = key.dup
446
- name[0] = name[0].downcase
534
+ name = key.dup.downcase
447
535
  enumerable[name] = enumerable.delete(key)
448
- camelcase_hash_keys!(enumerable[name]) if enumerable[name].is_a?(Enumerable)
536
+ downcase_hash_keys!(enumerable[name]) if enumerable[name].is_a?(Enumerable)
449
537
  end
450
538
  else
451
- 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) }
452
549
  end
453
550
  end
454
551
 
@@ -473,6 +570,34 @@ class Puppet::Provider::DscBaseProvider
473
570
  end
474
571
  end
475
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
+
476
601
  # Parses the DSC resource type definition to retrieve the names of any attributes which are specified as mandatory for get operations
477
602
  #
478
603
  # @param context [Object] the Puppet runtime context to operate in and send feedback to
@@ -505,6 +630,16 @@ class Puppet::Provider::DscBaseProvider
505
630
  context.type.attributes.select { |_name, properties| properties[:behaviour] == :parameter }.keys
506
631
  end
507
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
+
508
643
  # Look through a fully formatted string, replacing all instances where a value matches the formatted properties
509
644
  # of an instantiated variable with references to the variable instead. This allows us to pass complex and nested
510
645
  # CIM instances to the Invoke-DscResource parameter hash without constructing them *in* the hash.
@@ -520,7 +655,28 @@ class Puppet::Provider::DscBaseProvider
520
655
  modified_string
521
656
  end
522
657
 
523
- # 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
524
680
  # Credentials. As these values need to be serialized into PSCredential objects, return an array of
525
681
  # PowerShell lines, each of which instantiates a variable which holds the value as a PSCredential.
526
682
  # These credential variables can then be simply assigned in the parameter hash where needed.
@@ -551,10 +707,10 @@ class Puppet::Provider::DscBaseProvider
551
707
  # @param credential_hash [Hash] the Properties which define the PSCredential Object
552
708
  # @return [String] A line of PowerShell which defines the PSCredential object and stores it to a variable
553
709
  def format_pscredential(variable_name, credential_hash)
554
- "$#{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}'"
555
711
  end
556
712
 
557
- # 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
558
714
  # whether at the top level or nested inside of other CIM instances, and, where they are discovered, adds
559
715
  # those objects to the instantiated_variables hash as well as returning a line of PowerShell code which
560
716
  # will create the CIM object and store it in a variable. This then allows the CIM instances to be assigned
@@ -635,7 +791,7 @@ class Puppet::Provider::DscBaseProvider
635
791
  interpolate_variables(definition)
636
792
  end
637
793
 
638
- # 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
639
795
  # the `InvokeParams` hash which will be splatted to `Invoke-DscResource`, interpolating all previously
640
796
  # defined variables into the hash.
641
797
  #
@@ -649,7 +805,11 @@ class Puppet::Provider::DscBaseProvider
649
805
  }
650
806
  if resource.key?(:dscmeta_module_version)
651
807
  params[:ModuleName] = {}
652
- 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
653
813
  params[:ModuleName][:RequiredVersion] = resource[:dscmeta_module_version]
654
814
  else
655
815
  params[:ModuleName] = resource[:dscmeta_module_name]
@@ -691,7 +851,7 @@ class Puppet::Provider::DscBaseProvider
691
851
  params_block
692
852
  end
693
853
 
694
- # 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
695
855
  # all of the appropriate function and variable definitions, which will call Invoke-DscResource, and
696
856
  # will correct munge the results for returning to Puppet as a JSON object.
697
857
  #
@@ -706,13 +866,14 @@ class Puppet::Provider::DscBaseProvider
706
866
  # The postscript defines the invocation error and result handling; expects `$InvokeParams` to be defined
707
867
  postscript = File.new("#{template_path}/invoke_dsc_resource_postscript.ps1").read
708
868
  # The blocks define the variables to define for the postscript.
869
+ module_path_block = munge_psmodulepath(resource)
709
870
  credential_block = prepare_credentials(resource)
710
871
  cim_instances_block = prepare_cim_instances(resource)
711
872
  parameters_block = invoke_params(resource)
712
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
713
874
  clear_instantiated_variables!
714
875
 
715
- [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")
716
877
  end
717
878
 
718
879
  # Convert a Puppet/Ruby value into a PowerShell representation. Requires some slight additional
@@ -726,19 +887,18 @@ class Puppet::Provider::DscBaseProvider
726
887
  rescue RuntimeError => e
727
888
  raise unless e.message =~ /Sensitive \[value redacted\]/
728
889
 
729
- string = Pwsh::Util.format_powershell_value(unwrap(value))
730
- string.gsub(/#PuppetSensitive'}/, "'} # PuppetSensitive")
890
+ Pwsh::Util.format_powershell_value(unwrap(value))
731
891
  end
732
892
 
733
- # Unwrap sensitive strings for formatting, even inside an enumerable, appending '#PuppetSensitive'
734
- # 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.
735
895
  #
736
896
  # @param value [Object] The object to unwrap sensitive data inside of
737
897
  # @return [Object] The object with any sensitive strings unwrapped and annotated
738
898
  def unwrap(value)
739
899
  case value
740
900
  when Puppet::Pops::Types::PSensitiveType::Sensitive
741
- "#{value.unwrap}#PuppetSensitive"
901
+ "#{value.unwrap}#{SECRET_POSTFIX}"
742
902
  when Hash
743
903
  unwrapped = {}
744
904
  value.each do |k, v|
@@ -764,6 +924,47 @@ class Puppet::Provider::DscBaseProvider
764
924
  text.gsub("'", "''")
765
925
  end
766
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
+
767
968
  # While Puppet is aware of Sensitive data types, the PowerShell script is not
768
969
  # and so for debugging purposes must be redacted before being sent to debug
769
970
  # output but must *not* be redacted when sent to the PowerShell code manager.
@@ -771,15 +972,17 @@ class Puppet::Provider::DscBaseProvider
771
972
  # @param text [String] the text to redact
772
973
  # @return [String] the redacted text
773
974
  def redact_secrets(text)
774
- # Every secret unwrapped in this module will unwrap as "'secret' # PuppetSensitive" and, currently,
775
- # no known resources specify a SecureString instead of a PSCredential object. We therefore only
776
- # need to redact strings which look like password declarations.
777
- modified_text = text.gsub(/(?<=-Password )'.+' # PuppetSensitive/, "'#<Sensitive [value redacted]>'")
778
- if modified_text =~ /'.+' # PuppetSensitive/
779
- # Something has gone wrong, error loudly?
780
- else
781
- modified_text
782
- 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')
783
986
  end
784
987
 
785
988
  # Instantiate a PowerShell manager via the ruby-pwsh library and use it to invoke PowerShell.