ruby-pwsh 0.7.4 → 0.10.1

Sign up to get free protection for your applications and to get access to all the features.
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.