ruby-pwsh 0.1.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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