ruby-pwsh 0.1.0 → 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,38 @@
1
+ ---
2
+ version: 1.1.x.{build}
3
+ branches:
4
+ only:
5
+ - master
6
+ - release
7
+ clone_depth: 10
8
+ environment:
9
+ matrix:
10
+ -
11
+ RUBY_VERSION: 25-x64
12
+ CHECK: rubocop
13
+ -
14
+ RUBY_VERSION: 25
15
+ CHECK: spec
16
+ COVERAGE: yes
17
+ matrix:
18
+ fast_finish: true
19
+ install:
20
+ - set PATH=C:\Ruby%RUBY_VERSION%\bin;%PATH%
21
+ - bundle install --jobs 4 --retry 2
22
+ - type Gemfile.lock
23
+ build: off
24
+ build_script:
25
+ - dir .
26
+ test_script:
27
+ - ruby -v
28
+ - gem -v
29
+ - bundle -v
30
+ - pwsh -v
31
+ - bundle exec rake %CHECK%
32
+ notifications:
33
+ - provider: Email
34
+ to:
35
+ - nobody@nowhere.com
36
+ on_build_success: false
37
+ on_build_failure: false
38
+ on_build_status_changed: false
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'puppet/util/feature'
4
+
5
+ Puppet.features.add(:pwshlib, libs: ['ruby-pwsh'])
@@ -0,0 +1,666 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'puppet/resource_api/simple_provider'
4
+ require 'securerandom'
5
+ require 'ruby-pwsh'
6
+ require 'pathname'
7
+ require 'json'
8
+
9
+ class Puppet::Provider::DscBaseProvider < Puppet::ResourceApi::SimpleProvider
10
+ # Initializes the provider, preparing the class variables which cache:
11
+ # - the canonicalized resources across calls
12
+ # - query results
13
+ # - logon failures
14
+ def initialize
15
+ @@cached_canonicalized_resource = []
16
+ @@cached_query_results = []
17
+ @@logon_failures = []
18
+ end
19
+
20
+ # Look through a cache to retrieve the hashes specified, if they have been cached.
21
+ # Does so by seeing if each of the specified hashes is a subset of any of the hashes
22
+ # in the cache, so {foo: 1, bar: 2} would return if {foo: 1} was the search hash.
23
+ #
24
+ # @param cache [Array] the class variable containing cached hashes to search through
25
+ # @param hashes [Array] the list of hashes to search the cache for
26
+ # @return [Array] an array containing the matching hashes for the search condition, if any
27
+ def fetch_cached_hashes(cache, hashes)
28
+ cache.reject do |item|
29
+ matching_hash = hashes.select { |hash| (item.to_a - hash.to_a).empty? || (hash.to_a - item.to_a).empty? }
30
+ matching_hash.empty?
31
+ end.flatten
32
+ end
33
+
34
+ # Implements the canonicalize feature of the Resource API; this method is called first against any resources
35
+ # defined in the manifest, then again to conform the results from a get call. The method attempts to retrieve
36
+ # the DSC resource from the machine; if the resource is found, this method then compares the downcased values
37
+ # of the two hashes, overwriting the manifest value with the discovered one if they are case insensitively
38
+ # equivalent; this enables case insensitive but preserving behavior where a manifest declaration of a path as
39
+ # "c:/foo/bar" if discovered on disk as "C:\Foo\Bar" will canonicalize to the latter and prevent any flapping.
40
+ #
41
+ # @param context [Object] the Puppet runtime context to operate in and send feedback to
42
+ # @param resources [Hash] the hash of the resource to canonicalize from either manifest or invocation
43
+ # @return [Hash] returns a hash representing the current state of the object, if it exists
44
+ def canonicalize(context, resources)
45
+ canonicalized_resources = []
46
+ 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?
50
+ canonicalized = r.dup
51
+ @@cached_canonicalized_resource << r.dup
52
+ else
53
+ canonicalized[:name] = r[:name]
54
+ if r[:dsc_psdscrunascredential].nil?
55
+ canonicalized.delete(:dsc_psdscrunascredential)
56
+ else
57
+ canonicalized[:dsc_psdscrunascredential] = r[:dsc_psdscrunascredential]
58
+ end
59
+ downcased_result = recursively_downcase(canonicalized)
60
+ downcased_resource = recursively_downcase(r)
61
+ downcased_result.each do |key, value|
62
+ canonicalized[key] = r[key] unless downcased_resource[key] == value
63
+ canonicalized.delete(key) unless downcased_resource.keys.include?(key)
64
+ end
65
+ # Cache the actually canonicalized resource separately
66
+ @@cached_canonicalized_resource << canonicalized.dup
67
+ end
68
+ else
69
+ canonicalized = r
70
+ end
71
+ canonicalized_resources << canonicalized
72
+ end
73
+ context.debug("Canonicalized Resources: #{canonicalized_resources}")
74
+ canonicalized_resources
75
+ end
76
+
77
+ # Attempts to retrieve an instance of the DSC resource, invoking the `Get` method and passing any
78
+ # namevars as the Properties to Invoke-DscResource. The result object, if any, is compared to the
79
+ # specified properties in the Puppet Resource to decide whether it needs to be created, updated,
80
+ # deleted, or whether it is in the desired state.
81
+ #
82
+ # @param context [Object] the Puppet runtime context to operate in and send feedback to
83
+ # @param names [Hash] the hash of namevar properties and their values to use to get the resource
84
+ # @return [Hash] returns a hash representing the current state of the object, if it exists
85
+ def get(context, names = nil)
86
+ # Relies on the get_simple_filter feature to pass the namevars
87
+ # as an array containing the namevar parameters as a hash.
88
+ # This hash is functionally the same as a should hash as
89
+ # passed to the should_to_resource method.
90
+ context.debug('Collecting data from the DSC Resource')
91
+
92
+ # If the resource has already been queried, do not bother querying for it again
93
+ cached_results = fetch_cached_hashes(@@cached_query_results, names)
94
+ return cached_results unless cached_results.empty?
95
+
96
+ if @@cached_canonicalized_resource.empty?
97
+ mandatory_properties = {}
98
+ else
99
+ canonicalized_resource = @@cached_canonicalized_resource[0].dup
100
+ mandatory_properties = canonicalized_resource.select do |attribute, _value|
101
+ (mandatory_get_attributes(context) - namevar_attributes(context)).include?(attribute)
102
+ end
103
+ # If dsc_psdscrunascredential was specified, re-add it here.
104
+ mandatory_properties[:dsc_psdscrunascredential] = canonicalized_resource[:dsc_psdscrunascredential] if canonicalized_resource.keys.include?(:dsc_psdscrunascredential)
105
+ end
106
+ names.collect do |name|
107
+ name = { name: name } if name.is_a? String
108
+ invoke_get_method(context, name.merge(mandatory_properties))
109
+ end
110
+ end
111
+
112
+ # Attempts to set an instance of the DSC resource, invoking the `Set` method and thinly wrapping
113
+ # the `invoke_set_method` method; whether this method, `update`, or `delete` is called is entirely
114
+ # up to the Resource API based on the results
115
+ #
116
+ # @param context [Object] the Puppet runtime context to operate in and send feedback to
117
+ # @param name [String] the name of the resource being created
118
+ # @return [Hash] returns a hash indicating whether or not the resource is in the desired state, whether or not it requires a reboot, and any error messages captured.
119
+ def create(context, name, should)
120
+ context.debug("Creating '#{name}' with #{should.inspect}")
121
+ invoke_set_method(context, name, should)
122
+ end
123
+
124
+ # Attempts to set an instance of the DSC resource, invoking the `Set` method and thinly wrapping
125
+ # the `invoke_set_method` method; whether this method, `create`, or `delete` is called is entirely
126
+ # up to the Resource API based on the results
127
+ #
128
+ # @param context [Object] the Puppet runtime context to operate in and send feedback to
129
+ # @param name [String] the name of the resource being created
130
+ # @return [Hash] returns a hash indicating whether or not the resource is in the desired state, whether or not it requires a reboot, and any error messages captured.
131
+ def update(context, name, should)
132
+ context.debug("Updating '#{name}' with #{should.inspect}")
133
+ invoke_set_method(context, name, should)
134
+ end
135
+
136
+ # Attempts to set an instance of the DSC resource, invoking the `Set` method and thinly wrapping
137
+ # the `invoke_set_method` method; whether this method, `create`, or `update` is called is entirely
138
+ # up to the Resource API based on the results
139
+ #
140
+ # @param context [Object] the Puppet runtime context to operate in and send feedback to
141
+ # @param name [String] the name of the resource being created
142
+ # @return [Hash] returns a hash indicating whether or not the resource is in the desired state, whether or not it requires a reboot, and any error messages captured.
143
+ def delete(context, name)
144
+ context.debug("Deleting '#{name}'")
145
+ invoke_set_method(context, name, name.merge({ dsc_ensure: 'Absent' }))
146
+ end
147
+
148
+ # Invokes the `Get` method, passing the name_hash as the properties to use with `Invoke-DscResource`
149
+ # The PowerShell script returns a JSON representation of the DSC Resource's CIM Instance munged as
150
+ # best it can be for Ruby. Once that JSON is parsed into a hash this method further munges it to
151
+ # fit the expected property definitions. Finally, it returns the object for the Resource API to
152
+ # compare against and determine what future actions, if any, are needed.
153
+ #
154
+ # @param context [Object] the Puppet runtime context to operate in and send feedback to
155
+ # @param name_hash [Hash] the hash of namevars to be passed as properties to `Invoke-DscResource`
156
+ # @return [Hash] returns a hash representing the DSC resource munged to the representation the Puppet Type expects
157
+ def invoke_get_method(context, name_hash)
158
+ context.debug("retrieving #{name_hash.inspect}")
159
+
160
+ # Do not bother running if the logon credentials won't work
161
+ unless name_hash[:dsc_psdscrunascredential].nil?
162
+ return name_hash if logon_failed_already?(name_hash[:dsc_psdscrunascredential])
163
+ end
164
+
165
+ query_props = name_hash.select { |k, v| mandatory_get_attributes(context).include?(k) || (k == :dsc_psdscrunascredential && !v.nil?) }
166
+ resource = should_to_resource(query_props, context, 'get')
167
+ script_content = ps_script_content(resource)
168
+ context.debug("Script:\n #{redact_secrets(script_content)}")
169
+ output = ps_manager.execute(script_content)[:stdout]
170
+ context.err('Nothing returned') if output.nil?
171
+
172
+ data = JSON.parse(output)
173
+ context.debug("raw data received: #{data.inspect}")
174
+ error = data['errormessage']
175
+ unless error.nil?
176
+ # NB: We should have a way to stop processing this resource *now* without blowing up the whole Puppet run
177
+ # Raising an error stops processing but blows things up while context.err alerts but continues to process
178
+ if error =~ /Logon failure: the user has not been granted the requested logon type at this computer/
179
+ logon_error = "PSDscRunAsCredential account specified (#{name_hash[:dsc_psdscrunascredential]['user']}) does not have appropriate logon rights; are they an administrator?"
180
+ name_hash[:name].nil? ? context.err(logon_error) : context.err(name_hash[:name], logon_error)
181
+ @@logon_failures << name_hash[:dsc_psdscrunascredential].dup
182
+ # This is a hack to handle the query cache to prevent a second lookup
183
+ @@cached_query_results << name_hash # if fetch_cached_hashes(@@cached_query_results, [data]).empty?
184
+ else
185
+ context.err(error)
186
+ end
187
+ # Either way, something went wrong and we didn't get back a good result, so return nil
188
+ return nil
189
+ end
190
+ # DSC gives back information we don't care about; filter down to only
191
+ # those properties exposed in the type definition.
192
+ valid_attributes = context.type.attributes.keys.collect(&:to_s)
193
+ data.select! { |key, _value| valid_attributes.include?("dsc_#{key.downcase}") }
194
+ # Canonicalize the results to match the type definition representation;
195
+ # failure to do so will prevent the resource_api from comparing the result
196
+ # to the should hash retrieved from the resource definition in the manifest.
197
+ data.keys.each do |key|
198
+ type_key = "dsc_#{key.downcase}".to_sym
199
+ data[type_key] = data.delete(key)
200
+ camelcase_hash_keys!(data[type_key]) if data[type_key].is_a?(Enumerable)
201
+ end
202
+ # If a resource is found, it's present, so refill these two Puppet-only keys
203
+ data.merge!({ name: name_hash[:name] })
204
+ if ensurable?(context)
205
+ ensure_value = data.key?(:dsc_ensure) ? data[:dsc_ensure].downcase : 'present'
206
+ data.merge!({ ensure: ensure_value })
207
+ end
208
+ # TODO: Handle PSDscRunAsCredential flapping
209
+ # Resources do not return the account under which they were discovered, so re-add that
210
+ if name_hash[:dsc_psdscrunascredential].nil?
211
+ data.delete(:dsc_psdscrunascredential)
212
+ else
213
+ data.merge!({ dsc_psdscrunascredential: name_hash[:dsc_psdscrunascredential] })
214
+ end
215
+ # Cache the query to prevent a second lookup
216
+ @@cached_query_results << data.dup if fetch_cached_hashes(@@cached_query_results, [data]).empty?
217
+ context.debug("Returned to Puppet as #{data}")
218
+ data
219
+ end
220
+
221
+ # Invokes the `Set` method, passing the should hash as the properties to use with `Invoke-DscResource`
222
+ # The PowerShell script returns a JSON hash with key-value pairs indicating whether or not the resource
223
+ # is in the desired state, whether or not it requires a reboot, and any error messages captured.
224
+ #
225
+ # @param context [Object] the Puppet runtime context to operate in and send feedback to
226
+ # @param should [Hash] the desired state represented definition to pass as properties to Invoke-DscResource
227
+ # @return [Hash] returns a hash indicating whether or not the resource is in the desired state, whether or not it requires a reboot, and any error messages captured.
228
+ def invoke_set_method(context, name, should)
229
+ context.debug("Invoking Set Method for '#{name}' with #{should.inspect}")
230
+
231
+ # Do not bother running if the logon credentials won't work
232
+ unless should[:dsc_psdscrunascredential].nil?
233
+ return nil if logon_failed_already?(should[:dsc_psdscrunascredential])
234
+ end
235
+
236
+ apply_props = should.select { |k, _v| k.to_s =~ /^dsc_/ }
237
+ resource = should_to_resource(apply_props, context, 'set')
238
+ script_content = ps_script_content(resource)
239
+ context.debug("Script:\n #{redact_secrets(script_content)}")
240
+
241
+ output = ps_manager.execute(script_content)[:stdout]
242
+ context.err('Nothing returned') if output.nil?
243
+
244
+ data = JSON.parse(output)
245
+ context.debug(data)
246
+
247
+ context.err(data['errormessage']) unless data['errormessage'].empty?
248
+ # TODO: Implement this functionality for notifying a DSC reboot?
249
+ # notify_reboot_pending if data['rebootrequired'] == true
250
+ data
251
+ end
252
+
253
+ # Converts a Puppet resource hash into a hash with the information needed to call Invoke-DscResource,
254
+ # including the desired state, the path to the PowerShell module containing the resources, the invoke
255
+ # method, and metadata about the DSC Resource and Puppet Type.
256
+ #
257
+ # @param should [Hash] A hash representing the desired state of the DSC resource as defined in Puppet
258
+ # @param context [Object] the Puppet runtime context to operate in and send feedback to
259
+ # @param dsc_invoke_method [String] the method to pass to Invoke-DscResource: get, set, or test
260
+ # @return [Hash] a hash with the information needed to run `Invoke-DscResource`
261
+ def should_to_resource(should, context, dsc_invoke_method)
262
+ resource = {}
263
+ resource[:parameters] = {}
264
+ %i[name dscmeta_resource_friendly_name dscmeta_resource_name dscmeta_module_name dscmeta_module_version].each do |k|
265
+ resource[k] = context.type.definition[k]
266
+ end
267
+ should.each do |k, v|
268
+ next if k == :ensure
269
+ # PSDscRunAsCredential is considered a namevar and will always be passed, even if nil
270
+ # To prevent flapping during runs, remove it from the resource definition unless specified
271
+ next if k == :dsc_psdscrunascredential && v.nil?
272
+
273
+ resource[:parameters][k] = {}
274
+ resource[:parameters][k][:value] = v
275
+ %i[mof_type mof_is_embedded].each do |ky|
276
+ resource[:parameters][k][ky] = context.type.definition[:attributes][k][ky]
277
+ end
278
+ end
279
+ resource[:dsc_invoke_method] = dsc_invoke_method
280
+
281
+ # Because Puppet adds all of the modules to the LOAD_PATH we can be sure that the appropriate module lives here
282
+ # PROBLEM: This currently uses the downcased name, we need to capture the module name in the metadata I think.
283
+ root_module_path = $LOAD_PATH.select { |path| path.match?(%r{#{resource[:dscmeta_module_name].downcase}/lib}) }.first
284
+ resource[:vendored_modules_path] = File.expand_path(root_module_path + '/puppet_x/dsc_resources')
285
+ # resource[:vendored_modules_path] = File.expand_path(Pathname.new(__FILE__).dirname + '../../../' + 'puppet_x/dsc_resources')
286
+ resource[:attributes] = nil
287
+ context.debug("should_to_resource: #{resource.inspect}")
288
+ resource
289
+ end
290
+
291
+ # Return a UUID with the dashes turned into underscores to enable the specifying of guaranteed-unique
292
+ # variables in the PowerShell script.
293
+ #
294
+ # @return [String] a uuid with underscores instead of dashes.
295
+ def random_variable_name
296
+ # PowerShell variables can't include dashes
297
+ SecureRandom.uuid.gsub('-', '_')
298
+ end
299
+
300
+ # Return a Hash containing all of the variables defined for instantiation as well as the Ruby hash for their
301
+ # properties so they can be matched and replaced as needed.
302
+ #
303
+ # @return [Hash] containing all instantiated variables and the properties that they define
304
+ def instantiated_variables
305
+ @@instantiated_variables ||= {}
306
+ end
307
+
308
+ # Clear the instantiated variables hash to be ready for the next run
309
+ def clear_instantiated_variables!
310
+ @@instantiated_variables = {}
311
+ end
312
+
313
+ # Return true if the specified credential hash has already failed to execute a DSC resource due to
314
+ # a logon error, as when the account is not an administrator on the machine; otherwise returns false.
315
+ #
316
+ # @param [Hash] a credential hash with a user and password keys where the password is a sensitive string
317
+ # @return [Bool] true if the credential_hash has already failed logon, false otherwise
318
+ def logon_failed_already?(credential_hash)
319
+ @@logon_failures.any? do |failure_hash|
320
+ failure_hash['user'] == credential_hash['user'] && failure_hash['password'].unwrap == credential_hash['password'].unwrap
321
+ end
322
+ end
323
+
324
+ # Recursively transforms any enumerable, camelCasing any hash keys it finds
325
+ #
326
+ # @param enumerable [Enumerable] a string, array, hash, or other object to attempt to recursively downcase
327
+ # @return [Enumerable] returns the input object with hash keys recursively camelCased
328
+ def camelcase_hash_keys!(enumerable)
329
+ if enumerable.is_a?(Hash)
330
+ enumerable.keys.each do |key|
331
+ name = key.dup
332
+ name[0] = name[0].downcase
333
+ enumerable[name] = enumerable.delete(key)
334
+ camelcase_hash_keys!(enumerable[name]) if enumerable[name].is_a?(Enumerable)
335
+ end
336
+ else
337
+ enumerable.each { |item| camelcase_hash_keys!(item) if item.is_a?(Enumerable) }
338
+ end
339
+ end
340
+
341
+ # Recursively transforms any object, downcasing it to enable case insensitive comparisons
342
+ #
343
+ # @param object [Object] a string, array, hash, or other object to attempt to recursively downcase
344
+ # @return [Object] returns the input object recursively downcased
345
+ def recursively_downcase(object)
346
+ case object
347
+ when String
348
+ object.downcase
349
+ when Array
350
+ object.map { |item| recursively_downcase(item) }
351
+ when Hash
352
+ transformed = {}
353
+ object.transform_keys(&:downcase).each do |key, value|
354
+ transformed[key] = recursively_downcase(value)
355
+ end
356
+ transformed
357
+ else
358
+ object
359
+ end
360
+ end
361
+
362
+ # Checks to see whether the DSC resource being managed is defined as ensurable
363
+ #
364
+ # @param context [Object] the Puppet runtime context to operate in and send feedback to
365
+ # @return [Bool] returns true if the DSC Resource is ensurable, otherwise false.
366
+ def ensurable?(context)
367
+ context.type.attributes.keys.include?(:ensure)
368
+ end
369
+
370
+ # Parses the DSC resource type definition to retrieve the names of any attributes which are specified as mandatory for get operations
371
+ #
372
+ # @param context [Object] the Puppet runtime context to operate in and send feedback to
373
+ # @return [Array] returns an array of attribute names as symbols which are mandatory for get operations
374
+ def mandatory_get_attributes(context)
375
+ context.type.attributes.select { |_attribute, properties| properties[:mandatory_for_get] }.keys
376
+ end
377
+
378
+ # Parses the DSC resource type definition to retrieve the names of any attributes which are specified as mandatory for set operations
379
+ #
380
+ # @param context [Object] the Puppet runtime context to operate in and send feedback to
381
+ # @return [Array] returns an array of attribute names as symbols which are mandatory for set operations
382
+ def mandatory_set_attributes(context)
383
+ context.type.attributes.select { |_attribute, properties| properties[:mandatory_for_set] }.keys
384
+ end
385
+
386
+ # Parses the DSC resource type definition to retrieve the names of any attributes which are specified as namevars
387
+ #
388
+ # @param context [Object] the Puppet runtime context to operate in and send feedback to
389
+ # @return [Array] returns an array of attribute names as symbols which are namevars
390
+ def namevar_attributes(context)
391
+ context.type.attributes.select { |_attribute, properties| properties[:behaviour] == :namevar }.keys
392
+ end
393
+
394
+ # Look through a fully formatted string, replacing all instances where a value matches the formatted properties
395
+ # of an instantiated variable with references to the variable instead. This allows us to pass complex and nested
396
+ # CIM instances to the Invoke-DscResource parameter hash without constructing them *in* the hash.
397
+ #
398
+ # @param string [String] the string of text to search through for places an instantiated variable can be referenced
399
+ # @return [String] the string with references to instantiated variables instead of their properties
400
+ def interpolate_variables(string)
401
+ modified_string = string
402
+ # Always replace later-created variables first as they sometimes were built from earlier ones
403
+ instantiated_variables.reverse_each do |variable_name, ruby_definition|
404
+ modified_string = modified_string.gsub(format(ruby_definition), "$#{variable_name}")
405
+ end
406
+ modified_string
407
+ end
408
+
409
+ # Parses a resource definition (as from `should_to_resource`) for any properties which are PowerShell
410
+ # Credentials. As these values need to be serialized into PSCredential objects, return an array of
411
+ # PowerShell lines, each of which instantiates a variable which holds the value as a PSCredential.
412
+ # These credential variables can then be simply assigned in the parameter hash where needed.
413
+ #
414
+ # @param resource [Hash] a hash with the information needed to run `Invoke-DscResource`
415
+ # @return [String] An array of lines of PowerShell to instantiate PSCredentialObjects and store them in variables
416
+ def prepare_credentials(resource)
417
+ credentials_block = []
418
+ resource[:parameters].each do |_property_name, property_hash|
419
+ next unless property_hash[:mof_type] == 'PSCredential'
420
+ next if property_hash[:value].nil?
421
+
422
+ variable_name = random_variable_name
423
+ credential_hash = {
424
+ 'user' => property_hash[:value]['user'],
425
+ 'password' => escape_quotes(property_hash[:value]['password'].unwrap)
426
+ }
427
+ instantiated_variables.merge!(variable_name => credential_hash)
428
+ credentials_block << format_pscredential(variable_name, credential_hash)
429
+ end
430
+ credentials_block.join("\n")
431
+ credentials_block == [] ? '' : credentials_block
432
+ end
433
+
434
+ # Write a line of PowerShell which creates a PSCredential object and assigns it to a variable
435
+ #
436
+ # @param variable_name [String] the name of the Variable to assign the PSCredential object to
437
+ # @param credential_hash [Hash] the Properties which define the PSCredential Object
438
+ # @return [String] A line of PowerShell which defines the PSCredential object and stores it to a variable
439
+ def format_pscredential(variable_name, credential_hash)
440
+ definition = "$#{variable_name} = New-PSCredential -User #{credential_hash['user']} -Password '#{credential_hash['password']}' # PuppetSensitive"
441
+ definition
442
+ end
443
+
444
+ # Parses a resource definition (as from `should_to_resource`) for any properties which are CIM instances
445
+ # whether at the top level or nested inside of other CIM instances, and, where they are discovered, adds
446
+ # those objects to the instantiated_variables hash as well as returning a line of PowerShell code which
447
+ # will create the CIM object and store it in a variable. This then allows the CIM instances to be assigned
448
+ # by variable reference.
449
+ #
450
+ # @param resource [Hash] a hash with the information needed to run `Invoke-DscResource`
451
+ # @return [String] An array of lines of PowerShell to instantiate CIM Instances and store them in variables
452
+ def prepare_cim_instances(resource)
453
+ cim_instances_block = []
454
+ resource[:parameters].each do |_property_name, property_hash|
455
+ next unless property_hash[:mof_is_embedded]
456
+
457
+ # strip dsc_ from the beginning of the property name declaration
458
+ # name = property_name.to_s.gsub(/^dsc_/, '').to_sym
459
+ # Process nested CIM instances first as those neeed to be passed to higher-order
460
+ # instances and must therefore be declared before they must be referenced
461
+ cim_instance_hashes = nested_cim_instances(property_hash[:value]).flatten.reject(&:nil?)
462
+ # Sometimes the instances are an empty array
463
+ unless cim_instance_hashes.count.zero?
464
+ cim_instance_hashes.each do |instance|
465
+ variable_name = random_variable_name
466
+ instantiated_variables.merge!(variable_name => instance)
467
+ class_name = instance['cim_instance_type']
468
+ properties = instance.reject { |k, _v| k == 'cim_instance_type' }
469
+ cim_instances_block << format_ciminstance(variable_name, class_name, properties)
470
+ end
471
+ end
472
+ # We have to handle arrays of CIM instances slightly differently
473
+ if property_hash[:mof_type] =~ /\[\]$/
474
+ class_name = property_hash[:mof_type].gsub('[]', '')
475
+ property_hash[:value].each do |hash|
476
+ variable_name = random_variable_name
477
+ instantiated_variables.merge!(variable_name => hash)
478
+ cim_instances_block << format_ciminstance(variable_name, class_name, hash)
479
+ end
480
+ else
481
+ variable_name = random_variable_name
482
+ instantiated_variables.merge!(variable_name => property_hash[:value])
483
+ class_name = property_hash[:mof_type]
484
+ cim_instances_block << format_ciminstance(variable_name, class_name, property_hash[:value])
485
+ end
486
+ end
487
+ cim_instances_block == [] ? '' : cim_instances_block.join("\n")
488
+ end
489
+
490
+ # Recursively search for and return CIM instances nested in an enumerable
491
+ #
492
+ # @param enumerable [Enumerable] a hash or array which may contain CIM Instances
493
+ # @return [Hash] every discovered hash which does define a CIM Instance
494
+ def nested_cim_instances(enumerable)
495
+ enumerable.collect do |key, value|
496
+ if key.is_a?(Hash) && key.key?('cim_instance_type')
497
+ key
498
+ # TODO: Are there any cim instancees 3 levels deep, or only 2?
499
+ # if so, we should *also* keep searching and processing...
500
+ elsif key.is_a?(Enumerable)
501
+ nested_cim_instances(key)
502
+ elsif value.is_a?(Enumerable)
503
+ nested_cim_instances(value)
504
+ end
505
+ end
506
+ end
507
+
508
+ # Write a line of PowerShell which creates a CIM Instance and assigns it to a variable
509
+ #
510
+ # @param variable_name [String] the name of the Variable to assign the CIM Instance to
511
+ # @param class_name [String] the CIM Class to instantiate
512
+ # @param property_hash [Hash] the Properties which define the CIM Instance
513
+ # @return [String] A line of PowerShell which defines the CIM Instance and stores it to a variable
514
+ def format_ciminstance(variable_name, class_name, property_hash)
515
+ definition = "$#{variable_name} = New-CimInstance -ClientOnly -ClassName '#{class_name}' -Property #{format(property_hash)}"
516
+ # AWFUL HACK to make New-CimInstance happy ; it can't parse an array unless it's an array of Cim Instances
517
+ # definition = definition.gsub("@(@{'cim_instance_type'","[CimInstance[]]@(@{'cim_instance_type'")
518
+ # EVEN WORSE HACK - this one we can't even be sure it's a cim instance...
519
+ # but I don't _think_ anything but nested cim instances show up as hashes inside an array
520
+ definition = definition.gsub('@(@{', '[CimInstance[]]@(@{')
521
+ definition = interpolate_variables(definition)
522
+ definition
523
+ end
524
+
525
+ # Munge a resource definition (as from `should_to_resource`) into valid PowerShell which represents
526
+ # the `InvokeParams` hash which will be splatted to `Invoke-DscResource`, interpolating all previously
527
+ # defined variables into the hash.
528
+ #
529
+ # @param resource [Hash] a hash with the information needed to run `Invoke-DscResource`
530
+ # @return [String] A string representing the PowerShell definition of the InvokeParams hash
531
+ def invoke_params(resource)
532
+ params = {
533
+ Name: resource[:dscmeta_resource_friendly_name],
534
+ Method: resource[:dsc_invoke_method],
535
+ Property: {}
536
+ }
537
+ if resource.key?(:dscmeta_module_version)
538
+ params[:ModuleName] = {}
539
+ params[:ModuleName][:ModuleName] = "#{resource[:vendored_modules_path]}/#{resource[:dscmeta_module_name]}/#{resource[:dscmeta_module_name]}.psd1"
540
+ params[:ModuleName][:RequiredVersion] = resource[:dscmeta_module_version]
541
+ else
542
+ params[:ModuleName] = resource[:dscmeta_module_name]
543
+ end
544
+ resource[:parameters].each do |property_name, property_hash|
545
+ # strip dsc_ from the beginning of the property name declaration
546
+ name = property_name.to_s.gsub(/^dsc_/, '').to_sym
547
+ params[:Property][name] = if property_hash[:mof_type] == 'PSCredential'
548
+ # format can't unwrap Sensitive strings nested in arbitrary hashes/etc, so make
549
+ # the Credential hash interpolable as it will be replaced by a variable reference.
550
+ {
551
+ 'user' => property_hash[:value]['user'],
552
+ 'password' => escape_quotes(property_hash[:value]['password'].unwrap)
553
+ }
554
+ else
555
+ property_hash[:value]
556
+ end
557
+ end
558
+ params_block = interpolate_variables("$InvokeParams = #{format(params)}")
559
+ # HACK: make CIM instances work:
560
+ resource[:parameters].select { |_key, hash| hash[:mof_is_embedded] && hash[:mof_type] =~ /\[\]/ }.each do |_property_name, property_hash|
561
+ formatted_property_hash = interpolate_variables(format(property_hash[:value]))
562
+ params_block = params_block.gsub(formatted_property_hash, "[CimInstance[]]#{formatted_property_hash}")
563
+ end
564
+ params_block
565
+ end
566
+
567
+ # Given a resource definition (as from `should_to_resource`), return a PowerShell script which has
568
+ # all of the appropriate function and variable definitions, which will call Invoke-DscResource, and
569
+ # will correct munge the results for returning to Puppet as a JSON object.
570
+ #
571
+ # @param resource [Hash] a hash with the information needed to run `Invoke-DscResource`
572
+ # @return [String] A string representing the PowerShell script which will invoke the DSC Resource.
573
+ def ps_script_content(resource)
574
+ template_path = File.expand_path('../', __FILE__)
575
+ # Defines the helper functions
576
+ functions = File.new(template_path + '/invoke_dsc_resource_functions.ps1').read
577
+ # Defines the response hash and the runtime settings
578
+ preamble = File.new(template_path + '/invoke_dsc_resource_preamble.ps1').read
579
+ # The postscript defines the invocation error and result handling; expects `$InvokeParams` to be defined
580
+ postscript = File.new(template_path + '/invoke_dsc_resource_postscript.ps1').read
581
+ # The blocks define the variables to define for the postscript.
582
+ credential_block = prepare_credentials(resource)
583
+ cim_instances_block = prepare_cim_instances(resource)
584
+ parameters_block = invoke_params(resource)
585
+ # 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
586
+ clear_instantiated_variables!
587
+
588
+ content = [functions, preamble, credential_block, cim_instances_block, parameters_block, postscript].join("\n")
589
+ content
590
+ end
591
+
592
+ # Convert a Puppet/Ruby value into a PowerShell representation. Requires some slight additional
593
+ # munging over what is provided in the ruby-pwsh library, as it does not handle unwrapping Sensitive
594
+ # data types or interpolating Credentials.
595
+ #
596
+ # @param value [Object] The object to format into valid PowerShell
597
+ # @return [String] A string representation of the input value as valid PowerShell
598
+ def format(value)
599
+ Pwsh::Util.format_powershell_value(value)
600
+ rescue RuntimeError => e
601
+ raise unless e.message =~ /Sensitive \[value redacted\]/
602
+
603
+ string = Pwsh::Util.format_powershell_value(unwrap(value))
604
+ string.gsub(/#PuppetSensitive'}/, "'} # PuppetSensitive")
605
+ end
606
+
607
+ # Unwrap sensitive strings for formatting, even inside an enumerable, appending '#PuppetSensitive'
608
+ # to the end of the string in preparation for gsub cleanup.
609
+ #
610
+ # @param value [Object] The object to unwrap sensitive data inside of
611
+ # @return [Object] The object with any sensitive strings unwrapped and annotated
612
+ def unwrap(value)
613
+ if value.class.name == 'Puppet::Pops::Types::PSensitiveType::Sensitive'
614
+ "#{value.unwrap}#PuppetSensitive"
615
+ elsif value.class.name == 'Hash'
616
+ unwrapped = {}
617
+ value.each do |k, v|
618
+ unwrapped[k] = unwrap(v)
619
+ end
620
+ unwrapped
621
+ elsif value.class.name == 'Array'
622
+ unwrapped = []
623
+ value.each do |v|
624
+ unwrapped << unwrap(v)
625
+ end
626
+ unwrapped
627
+ else
628
+ value
629
+ end
630
+ end
631
+
632
+ # Escape any nested single quotes in a Sensitive string
633
+ #
634
+ # @param text [String] the text to escape
635
+ # @return [String] the escaped text
636
+ def escape_quotes(text)
637
+ text.gsub("'", "''")
638
+ end
639
+
640
+ # While Puppet is aware of Sensitive data types, the PowerShell script is not
641
+ # and so for debugging purposes must be redacted before being sent to debug
642
+ # output but must *not* be redacted when sent to the PowerShell code manager.
643
+ #
644
+ # @param text [String] the text to redact
645
+ # @return [String] the redacted text
646
+ def redact_secrets(text)
647
+ # Every secret unwrapped in this module will unwrap as "'secret' # PuppetSensitive" and, currently,
648
+ # no known resources specify a SecureString instead of a PSCredential object. We therefore only
649
+ # need to redact strings which look like password declarations.
650
+ modified_text = text.gsub(/(?<=-Password )'.+' # PuppetSensitive/, "'#<Sensitive [value redacted]>'")
651
+ if modified_text =~ /'.+' # PuppetSensitive/
652
+ # Something has gone wrong, error loudly?
653
+ else
654
+ modified_text
655
+ end
656
+ end
657
+
658
+ # Instantiate a PowerShell manager via the ruby-pwsh library and use it to invoke PowerShell.
659
+ # Definiing it here allows re-use of a single instance instead of continually instantiating and
660
+ # tearing a new instance down for every call.
661
+ def ps_manager
662
+ debug_output = Puppet::Util::Log.level == :debug
663
+ # TODO: Allow you to specify an alternate path, either to pwsh generally or a specific pwsh path.
664
+ Pwsh::Manager.instance(Pwsh::Manager.powershell_path, Pwsh::Manager.powershell_args, debug: debug_output)
665
+ end
666
+ end