ruby-pwsh 2.0.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 696f0411f70a59f1e487088dd2e90c6f16c66949f7872c05d29ac53a5e86cc65
4
- data.tar.gz: 7c2e373fe612fedc43a84b85afd267239de4fff91e5e5a12dc89de3fa8896231
3
+ metadata.gz: d87a118d1771f309420051ae1204ceef19fd07b749bfe6ae00f09cd5f4cb0dea
4
+ data.tar.gz: defedaf6b72f61b033cdf576b83925f2796f22ef16e1f32269d8f20b0ab35f9a
5
5
  SHA512:
6
- metadata.gz: 1fffcd2e377099b3a2d4b58787ce5d899f21c613f6a81c66ca0c555a7bcc3c02ec22514850aa60bcb56681cdf156ac86727a3c795e8f527a5c08c56a84d5dd1b
7
- data.tar.gz: e32671cf64bd4cd3fecb838ce913c345a5169500a5c5698dc1acbec738e611fbe36e40e84d39ccd6a93aa703b80abaf343ced4da7bb5d655f2a68fe3e9868219
6
+ metadata.gz: c90e9942e0bdd31c33e1cb965795625726adb919d98b599b5008840ddf91291129b3f2a740dc26baff6495b3fecc7cc5330c7136e868b25c572662c50823d3db
7
+ data.tar.gz: 2f130b671873a490c2e811994fb635a47a37882190e1cd4a98e934fde5c08a1577d31f5b21eee7f0866285f6f85e0fdc5599bce18b9502c9dd9d3b04f3f36031
@@ -12,8 +12,11 @@ class Puppet::Provider::DscBaseProvider # rubocop:disable Metrics/ClassLength
12
12
  # - logon failures
13
13
  def initialize
14
14
  @cached_canonicalized_resource = []
15
- @cached_query_results = []
15
+ @cached_canonicalize_results = [] # Cache for invoke_get_method calls from canonicalize only
16
+ @cached_query_results = [] # Cache for invoke_get_method calls from get only
16
17
  @cached_test_results = []
18
+ @cached_fresh_get_results = {}
19
+ @insync_property_cache = {}
17
20
  @logon_failures = []
18
21
  @timeout = nil # default timeout, ps_manager.execute is expecting nil by default..
19
22
  super
@@ -61,7 +64,11 @@ class Puppet::Provider::DscBaseProvider # rubocop:disable Metrics/ClassLength
61
64
  canonicalized = r.dup
62
65
  @cached_canonicalized_resource << r.dup
63
66
  else
64
- canonicalized = invoke_get_method(context, r)
67
+ # Use a separate cache for canonicalize's Get calls so we don't pollute
68
+ # @cached_query_results, which get() uses for the "is" state comparison.
69
+ # Sharing a cache between canonicalize and get caused the Resource API to
70
+ # see identical "should" and "is" values, killing per-property report detail.
71
+ canonicalized = invoke_get_method_for_canonicalize(context, r)
65
72
  # If the resource could not be found or was returned as absent, skip case munging and
66
73
  # treat the manifest values as canonical since the resource is being created.
67
74
  # rubocop:disable Metrics/BlockNesting
@@ -136,6 +143,21 @@ class Puppet::Provider::DscBaseProvider # rubocop:disable Metrics/ClassLength
136
143
  cached_results = fetch_cached_hashes(@cached_query_results, names)
137
144
  return cached_results unless cached_results.empty?
138
145
 
146
+ # Use the raw system state from canonicalize's Get call if available.
147
+ # @cached_canonicalize_results has the unmodified DSC Get response (before
148
+ # canonicalize normalized casing). This gives the Resource API real "is" data
149
+ # with all property values, enabling per-property "Changed from" reporting.
150
+ unless @cached_canonicalize_results.empty?
151
+ canon_results = fetch_cached_hashes(@cached_canonicalize_results, names)
152
+ unless canon_results.empty?
153
+ # Cache these as query results too so subsequent get() calls find them
154
+ canon_results.each do |r|
155
+ @cached_query_results << r.dup if fetch_cached_hashes(@cached_query_results, [r]).empty?
156
+ end
157
+ return canon_results
158
+ end
159
+ end
160
+
139
161
  if @cached_canonicalized_resource.empty?
140
162
  mandatory_properties = {}
141
163
  else
@@ -166,6 +188,12 @@ class Puppet::Provider::DscBaseProvider # rubocop:disable Metrics/ClassLength
166
188
  # If should is an array instead of a hash and only has one entry, use that.
167
189
  should = should.first if should.is_a?(Array) && should.length == 1
168
190
 
191
+ # DSC_WORKAROUND logging disabled — the insync? tuple returns now provide
192
+ # accurate per-property change detail in PE Events, making this redundant.
193
+ # Retained commented out for future debugging if needed.
194
+ # fresh_is = get_fresh_state_for_logging(context, name, should)
195
+ # log_change_detail(context, name, fresh_is || is, should)
196
+
169
197
  # for compatibility sake, we use dsc_ensure instead of ensure, so context.type.ensurable? does not work
170
198
  if context.type.attributes.key?(:dsc_ensure)
171
199
  # HACK: If the DSC Resource is ensurable but doesn't report a default value
@@ -201,6 +229,120 @@ class Puppet::Provider::DscBaseProvider # rubocop:disable Metrics/ClassLength
201
229
  end
202
230
  end
203
231
 
232
+ # Compares the is and should hashes and logs per-property change detail.
233
+ # The Resource API does not generate per-property change events when custom_insync
234
+ # is declared as a feature, so we emit them ourselves to populate report detail.
235
+ #
236
+ # @param context [Object] the Puppet runtime context to operate in and send feedback to
237
+ # @param name [String] the name of the resource being changed
238
+ # @param is [Hash] the current state of the resource
239
+ # @param should [Hash] the desired state of the resource
240
+ def log_change_detail(context, name, is_value, should)
241
+ return if is_value.nil? || should.nil?
242
+
243
+ # Build context info for the log line
244
+ type_name = begin
245
+ context.type.definition[:name]
246
+ rescue StandardError
247
+ nil
248
+ end
249
+ dsc_module = begin
250
+ context.type.definition[:dscmeta_module_name]
251
+ rescue StandardError
252
+ nil
253
+ end
254
+ resource_title = name.is_a?(Hash) ? name[:name] : name
255
+ # Try to extract the declaring Puppet class from tags if available
256
+ tags = should[:tag] || should[:tags]
257
+ declaring_class = if tags.is_a?(Array)
258
+ # Tags include type name, title, and all containing classes.
259
+ # Class tags contain '::' — pick the most specific one.
260
+ tags.reverse.find { |t| t.include?('::') }
261
+ end
262
+
263
+ should.each do |property, desired_value|
264
+ next unless property.to_s.start_with?('dsc_')
265
+ next if property == :dsc_psdscrunascredential
266
+ # Skip namevars — they're identifiers, not managed properties
267
+ next if namevar_attributes(context).include?(property)
268
+
269
+ current_value = is_value[property]
270
+ # Skip if values are the same (case-insensitive for strings)
271
+ next if same?(recursively_downcase(current_value), recursively_downcase(desired_value))
272
+
273
+ mof_type = begin
274
+ context.type.definition[:attributes][property][:mof_type]
275
+ rescue StandardError
276
+ nil
277
+ end
278
+
279
+ detail = "DSC_WORKAROUND: #{property} '#{current_value}' -> '#{desired_value}'"
280
+ detail += " (#{mof_type})" if mof_type
281
+ detail += " | resource: #{type_name}[#{resource_title}]" if type_name
282
+ detail += " | dsc_module: #{dsc_module}" if dsc_module
283
+ detail += " | class: #{declaring_class}" if declaring_class
284
+ context.notice(detail)
285
+ end
286
+ end
287
+
288
+ # Performs a fresh DSC Get call completely bypassing all caches to retrieve the actual
289
+ # current system state. Used by both insync? (for accurate property comparison and
290
+ # change_message tuples) and set() (for DSC_WORKAROUND log entries).
291
+ #
292
+ # @param context [Object] the Puppet runtime context to operate in and send feedback to
293
+ # @param name [Hash] the name hash for the resource
294
+ # @param should [Hash] the desired state hash (used to extract query properties)
295
+ # @return [Hash] returns a hash with dsc_ prefixed keys and current system values, or nil on failure
296
+ def perform_fresh_get(context, name, should)
297
+ query_props = should.select { |k, v| mandatory_get_attributes(context).include?(k) || (k == :dsc_psdscrunascredential && !v.nil?) }
298
+ data = invoke_dsc_resource(context, name, query_props, 'get')
299
+ return nil if data.nil?
300
+
301
+ # Minimal key processing to match the dsc_ prefix format used by Puppet types
302
+ valid_attributes = context.type.attributes.keys.collect(&:to_s)
303
+ result = {}
304
+ data.each do |key, value|
305
+ type_key = :"dsc_#{key.downcase}"
306
+ next unless valid_attributes.include?(type_key.to_s)
307
+
308
+ # Sanitize .NET serialization artifacts (e.g., "System.Object[]")
309
+ if value.is_a?(String) && value =~ /^System\.\w+\[\]$/
310
+ result[type_key] = nil
311
+ else
312
+ # Normalize CIM instances the same way invoke_get_method does,
313
+ # so fresh values are comparable to canonicalized should values.
314
+ if value.is_a?(Enumerable)
315
+ downcase_hash_keys!(value)
316
+ munge_cim_instances!(value)
317
+ end
318
+ result[type_key] = value
319
+ end
320
+ end
321
+ result[:name] = name.is_a?(Hash) ? name[:name] : name
322
+ result
323
+ rescue StandardError => e
324
+ context.debug("perform_fresh_get failed: #{e.message}")
325
+ nil
326
+ end
327
+
328
+ # Caching wrapper around perform_fresh_get — one fresh DSC Get per resource,
329
+ # reused across all properties during that resource's insync? calls.
330
+ #
331
+ # @param context [Object] the Puppet runtime context to operate in and send feedback to
332
+ # @param name [Hash] the name hash for the resource
333
+ # @param should [Hash] the desired state hash (used to extract query properties)
334
+ # @return [Hash] returns a hash with dsc_ prefixed keys and current system values, or nil on failure
335
+ def get_cached_fresh_state(context, name, should)
336
+ cache_key = name.is_a?(Hash) ? name[:name] : name
337
+ context.debug("INSYNC_DIAG: get_cached_fresh_state called, cache_key=#{cache_key.inspect}, name class=#{name.class}, cached=#{@cached_fresh_get_results.key?(cache_key)}")
338
+ unless @cached_fresh_get_results.key?(cache_key)
339
+ result = perform_fresh_get(context, name, should)
340
+ context.debug("INSYNC_DIAG: perform_fresh_get returned #{result.nil? ? 'nil' : result.keys.inspect}")
341
+ @cached_fresh_get_results[cache_key] = result
342
+ end
343
+ @cached_fresh_get_results[cache_key]
344
+ end
345
+
204
346
  # Attempts to set an instance of the DSC resource, invoking the `Set` method and thinly wrapping
205
347
  # the `invoke_set_method` method; whether this method, `update`, or `delete` is called is entirely
206
348
  # up to the Resource API based on the results
@@ -341,24 +483,115 @@ class Puppet::Provider::DscBaseProvider # rubocop:disable Metrics/ClassLength
341
483
  data
342
484
  end
343
485
 
344
- # Determine if the DSC Resource is in the desired state, invoking the `Test` method unless it's
345
- # already been run for the resource, in which case reuse the result instead of checking for each
346
- # property. This behavior is only triggered if the validation_mode is set to resource; by default
347
- # it is set to property and uses the default property comparison logic in Puppet::Property.
486
+ # Compare two values with type coercion. Handles the mismatches common in DSC:
487
+ # integers vs strings, case differences, array ordering, and nested CIM instances.
488
+ # Uses the same recursively_sort/recursively_downcase helpers the provider already
489
+ # relies on for canonicalize and log_change_detail comparisons.
490
+ #
491
+ # @param is_value [Object] the current value from a fresh DSC Get
492
+ # @param should_value [Object] the desired value from the Puppet manifest
493
+ # @return [Boolean] true if the values are equivalent
494
+ def values_equal?(is_value, should_value)
495
+ return true if is_value == should_value
496
+
497
+ # Handle nil/empty equivalence
498
+ is_empty = is_value.nil? || (is_value.respond_to?(:empty?) && is_value.empty?)
499
+ should_empty = should_value.nil? || (should_value.respond_to?(:empty?) && should_value.empty?)
500
+ return true if is_empty && should_empty
501
+
502
+ # Normalize and compare: sort for order-insensitive array/hash comparison,
503
+ # downcase for case-insensitive string comparison, handles nested structures.
504
+ same?(recursively_downcase(is_value), recursively_downcase(should_value))
505
+ end
506
+
507
+ # Determine if the DSC Resource is in the desired state, using fresh DSC Get
508
+ # results to provide accurate property comparison and real change messages.
509
+ #
510
+ # For validation_mode: resource, delegates entirely to DSC Test (unchanged).
511
+ #
512
+ # For validation_mode: property (default), performs a fresh DSC Get (one per
513
+ # resource, cached) that bypasses the get()/canonicalize pipeline, then compares
514
+ # each dsc_ property against the desired value. Returns:
515
+ # - true if the property matches (suppresses false positive events)
516
+ # - [false, "'current' -> 'desired'"] if the property genuinely differs
517
+ # (the RSAPI uses the change_message as the Event text in PE)
518
+ # - nil to fall through to default RSAPI comparison (non-dsc_ properties,
519
+ # or if the fresh Get failed)
348
520
  #
349
521
  # @param context [Object] the Puppet runtime context to operate in and send feedback to
350
522
  # @param name [String] the name of the resource being tested
351
- # @param is_hash [Hash] the current state of the resource on the system
523
+ # @param property_name [Symbol] the name of the property being compared
524
+ # @param _is_hash [Hash] the current state of the resource on the system (unused, required by RSAPI signature)
352
525
  # @param should_hash [Hash] the desired state of the resource per the manifest
353
- # @return [Boolean, Void] returns true/false if the resource is/isn't in the desired state and
354
- # the validation mode is set to resource, otherwise nil.
355
- def insync?(context, name, _property_name, _is_hash, should_hash)
356
- return nil if should_hash[:validation_mode] != 'resource'
526
+ # @return [Boolean, Array, Void] returns true/false/[false, message] if the resource
527
+ # is/isn't in the desired state, or nil to fall through to default property comparison.
528
+ def insync?(context, name, property_name, _is_hash, should_hash)
529
+ # Detect corrective change check: after insync? returns [false, msg], Puppet calls
530
+ # insync? again for the same property. Return nil so default comparison handles it.
531
+ cache_key = name.is_a?(Hash) ? name[:name] : name
532
+ property_key = "#{cache_key}_#{property_name}"
533
+ return nil if @insync_property_cache.delete(property_key)
534
+
535
+ if should_hash[:validation_mode] == 'resource'
536
+ insync_resource_mode(context, name, property_name, should_hash)
537
+ else
538
+ insync_property_mode(context, name, property_name, should_hash)
539
+ end
540
+ end
357
541
 
542
+ private
543
+
544
+ # Resource validation mode: DSC Test is the authority on overall sync state.
545
+ def insync_resource_mode(context, name, property_name, should_hash)
546
+ should_value = should_hash.is_a?(Hash) ? should_hash[property_name] : nil
358
547
  prior_result = fetch_cached_hashes(@cached_test_results, [name])
359
- prior_result.empty? ? invoke_test_method(context, name, should_hash) : prior_result.first[:in_desired_state]
548
+ test_result = prior_result.empty? ? invoke_test_method(context, name, should_hash) : prior_result.first[:in_desired_state]
549
+ in_sync = test_result.is_a?(Array) ? test_result.first : test_result
550
+
551
+ return true if in_sync
552
+
553
+ # DSC Test says out of sync. Suppress non-dsc_ and nil/empty properties.
554
+ return true unless property_name.to_s.start_with?('dsc_')
555
+ return true if should_value.nil? || (should_value.respond_to?(:empty?) && should_value.empty?)
556
+
557
+ # Fresh Get comparison for dsc_ properties with values
558
+ compare_fresh_value(context, name, property_name, should_hash, report_on_failure: true)
559
+ end
560
+
561
+ # Property validation mode: only intervene for dsc_ properties with a desired value.
562
+ def insync_property_mode(context, name, property_name, should_hash)
563
+ return nil unless property_name.to_s.start_with?('dsc_')
564
+
565
+ should_value = should_hash.is_a?(Hash) ? should_hash[property_name] : nil
566
+ return nil if should_value.nil? || (should_value.respond_to?(:empty?) && should_value.empty?)
567
+
568
+ compare_fresh_value(context, name, property_name, should_hash, report_on_failure: false)
360
569
  end
361
570
 
571
+ # Shared fresh Get comparison for both validation modes.
572
+ def compare_fresh_value(context, name, property_name, should_hash, report_on_failure:)
573
+ should_value = should_hash.is_a?(Hash) ? should_hash[property_name] : nil
574
+ property_key = "#{name.is_a?(Hash) ? name[:name] : name}_#{property_name}"
575
+ fresh_state = get_cached_fresh_state(context, name, should_hash)
576
+
577
+ if fresh_state.nil?
578
+ if report_on_failure
579
+ @insync_property_cache[property_key] = true
580
+ return [false, "#{property_name} changed '' to '#{should_value}'"]
581
+ end
582
+
583
+ return nil
584
+ end
585
+
586
+ fresh_value = fresh_state[property_name]
587
+ return true if values_equal?(fresh_value, should_value)
588
+
589
+ @insync_property_cache[property_key] = true
590
+ [false, "#{property_name} changed '#{fresh_value}' to '#{should_value}'"]
591
+ end
592
+
593
+ public
594
+
362
595
  # Invokes the `Get` method, passing the name_hash as the properties to use with `Invoke-DscResource`
363
596
  # The PowerShell script returns a JSON representation of the DSC Resource's CIM Instance munged as
364
597
  # best it can be for Ruby. Once that JSON is parsed into a hash this method further munges it to
@@ -368,7 +601,7 @@ class Puppet::Provider::DscBaseProvider # rubocop:disable Metrics/ClassLength
368
601
  # @param context [Object] the Puppet runtime context to operate in and send feedback to
369
602
  # @param name_hash [Hash] the hash of namevars to be passed as properties to `Invoke-DscResource`
370
603
  # @return [Hash] returns a hash representing the DSC resource munged to the representation the Puppet Type expects
371
- def invoke_get_method(context, name_hash) # rubocop:disable Metrics/AbcSize
604
+ def invoke_get_method(context, name_hash)
372
605
  context.debug("retrieving #{name_hash.inspect}")
373
606
 
374
607
  query_props = name_hash.select { |k, v| mandatory_get_attributes(context).include?(k) || (k == :dsc_psdscrunascredential && !v.nil?) }
@@ -387,29 +620,10 @@ class Puppet::Provider::DscBaseProvider # rubocop:disable Metrics/ClassLength
387
620
  data.keys.each do |key|
388
621
  type_key = :"dsc_#{key.downcase}"
389
622
  data[type_key] = data.delete(key)
390
-
391
- # Special handling for CIM Instances
392
- if data[type_key].is_a?(Enumerable)
393
- downcase_hash_keys!(data[type_key])
394
- munge_cim_instances!(data[type_key])
395
- end
396
-
397
- # Convert DateTime back to appropriate type
398
- if context.type.attributes[type_key][:mof_type] =~ /DateTime/i && !data[type_key].nil?
399
- data[type_key] = begin
400
- Puppet::Pops::Time::Timestamp.parse(data[type_key]) if context.type.attributes[type_key][:mof_type] =~ /DateTime/i && !data[type_key].nil?
401
- rescue ArgumentError, TypeError => e
402
- # Catch any failures in the parse, output them to the context and then return nil
403
- context.err("Value returned for DateTime (#{data[type_key].inspect}) failed to parse: #{e}")
404
- nil
405
- end
406
- end
407
- # PowerShell does not distinguish between a return of empty array/string
408
- # and null but Puppet does; revert to those values if specified.
409
- data[type_key] = [] if data[type_key].nil? && query_props.key?(type_key) && query_props[type_key].is_a?(Array)
623
+ canonicalize_get_value!(context, data, type_key, query_props)
410
624
  end
411
- # If a resource is found, it's present, so refill this Puppet-only key
412
- data[:name] = name_hash[:name]
625
+ sanitize_dotnet_artifacts!(context, data)
626
+ preserve_namevar_values!(context, data, name_hash)
413
627
 
414
628
  data = stringify_nil_attributes(context, data)
415
629
 
@@ -431,6 +645,78 @@ class Puppet::Provider::DscBaseProvider # rubocop:disable Metrics/ClassLength
431
645
  data
432
646
  end
433
647
 
648
+ # Invokes the `Get` method for canonicalize only, using a separate cache from get().
649
+ # This prevents canonicalize from polluting @cached_query_results, which get() relies on
650
+ # to return the "is" state for the Resource API's property-by-property comparison.
651
+ #
652
+ # @param context [Object] the Puppet runtime context to operate in and send feedback to
653
+ # @param name_hash [Hash] the hash of namevars to be passed as properties to `Invoke-DscResource`
654
+ # @return [Hash] returns a hash representing the DSC resource munged to the representation the Puppet Type expects
655
+ def invoke_get_method_for_canonicalize(context, name_hash)
656
+ # Check the canonicalize-specific cache first
657
+ cached = fetch_cached_hashes(@cached_canonicalize_results, [name_hash.select { |k, _v| namevar_attributes(context).include?(k) }])
658
+ return cached.first unless cached.empty?
659
+
660
+ # Call invoke_get_method which will do the actual DSC Get call.
661
+ # It will also cache to @cached_query_results as a side effect — remove that entry
662
+ # so get() is forced to do its own fresh lookup later.
663
+ result = invoke_get_method(context, name_hash)
664
+
665
+ # Remove the entry that invoke_get_method just added to @cached_query_results
666
+ # so that get() will do its own fresh DSC Get call and produce independent "is" data.
667
+ @cached_query_results.select! do |item|
668
+ matching = fetch_cached_hashes([item], [name_hash.select { |k, _v| namevar_attributes(context).include?(k) }])
669
+ matching.empty?
670
+ end
671
+
672
+ # Cache in our own separate cache
673
+ @cached_canonicalize_results << result.dup if result && fetch_cached_hashes(@cached_canonicalize_results, [result]).empty?
674
+
675
+ result
676
+ end
677
+
678
+ # Canonicalize a single value returned by DSC Get: handle CIM instances, DateTime, and empty arrays.
679
+ def canonicalize_get_value!(context, data, type_key, query_props)
680
+ # Special handling for CIM Instances
681
+ if data[type_key].is_a?(Enumerable)
682
+ downcase_hash_keys!(data[type_key])
683
+ munge_cim_instances!(data[type_key])
684
+ end
685
+
686
+ # Convert DateTime back to appropriate type
687
+ if context.type.attributes[type_key][:mof_type] =~ /DateTime/i && !data[type_key].nil?
688
+ data[type_key] = begin
689
+ Puppet::Pops::Time::Timestamp.parse(data[type_key])
690
+ rescue ArgumentError, TypeError => e
691
+ context.err("Value returned for DateTime (#{data[type_key].inspect}) failed to parse: #{e}")
692
+ nil
693
+ end
694
+ end
695
+ # PowerShell does not distinguish between a return of empty array/string
696
+ # and null but Puppet does; revert to those values if specified.
697
+ data[type_key] = [] if data[type_key].nil? && query_props.key?(type_key) && query_props[type_key].is_a?(Array)
698
+ end
699
+
700
+ # Sanitize .NET serialization artifacts (e.g., "System.Object[]") to nil.
701
+ def sanitize_dotnet_artifacts!(context, data)
702
+ data.each do |key, value|
703
+ next unless value.is_a?(String) && value =~ /^System\.\w+\[\]$/
704
+
705
+ context.debug("Sanitizing .NET serialization artifact for #{key}: #{value} -> nil")
706
+ data[key] = nil
707
+ end
708
+ end
709
+
710
+ # Preserve namevar values from the input hash when DSC returns nil/empty.
711
+ def preserve_namevar_values!(context, data, name_hash)
712
+ data[:name] = name_hash[:name]
713
+ namevar_attributes(context).each do |namevar|
714
+ next unless name_hash.key?(namevar)
715
+
716
+ data[namevar] = name_hash[namevar] if data[namevar].nil? || (data[namevar].respond_to?(:empty?) && data[namevar].empty?)
717
+ end
718
+ end
719
+
434
720
  # Invokes the `Set` method, passing the should hash as the properties to use with `Invoke-DscResource`
435
721
  # The PowerShell script returns a JSON hash with key-value pairs indicating whether or not the resource
436
722
  # is in the desired state, whether or not it requires a reboot, and any error messages captured.
data/lib/pwsh/version.rb CHANGED
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Pwsh
4
4
  # The version of the ruby-pwsh gem
5
- VERSION = '2.0.0'
5
+ VERSION = '2.0.1'
6
6
  end
@@ -47,7 +47,7 @@ RSpec.describe 'DSC Acceptance: Class-Based Resource' do
47
47
  first_run_result = powershell.execute(command)
48
48
  expect(first_run_result[:exitcode]).to be(2)
49
49
  expect(first_run_result[:native_stdout]).to match(//)
50
- expect(first_run_result[:native_stdout]).to match(/dsc_description changed to 'Example role capability file'/)
50
+ expect(first_run_result[:native_stdout]).to match(/dsc_description changed.*to 'Example role capability file'/)
51
51
  expect(first_run_result[:native_stdout]).to match(/Creating: Finished/)
52
52
  expect(first_run_result[:native_stdout]).to match(/Applied catalog/)
53
53
  second_run_result = powershell.execute(command)
@@ -123,11 +123,11 @@ RSpec.describe 'DSC Acceptance: Complex' do
123
123
  # Web content index created
124
124
  expect(first_run_result[:native_stdout]).to match(%r{File\[WebContentIndex\]/ensure: defined content as '.+'})
125
125
  # Web site created
126
- expect(first_run_result[:native_stdout]).to match(%r{Dsc_xwebsite\[NewWebsite\]/dsc_siteid: dsc_siteid changed to 7})
126
+ expect(first_run_result[:native_stdout]).to match(%r{Dsc_xwebsite\[NewWebsite\]/dsc_siteid: dsc_siteid changed.*to 7})
127
127
  expect(first_run_result[:native_stdout]).to match(%r{Dsc_xwebsite\[NewWebsite\]/dsc_ensure: dsc_ensure changed 'Absent' to 'Present'})
128
- expect(first_run_result[:native_stdout]).to match(%r{Dsc_xwebsite\[NewWebsite\]/dsc_physicalpath: dsc_physicalpath changed to '.+fixtures/website'})
129
- expect(first_run_result[:native_stdout]).to match(%r{Dsc_xwebsite\[NewWebsite\]/dsc_state: dsc_state changed to 'Started'})
130
- expect(first_run_result[:native_stdout]).to match(%r{Dsc_xwebsite\[NewWebsite\]/dsc_serverautostart: dsc_serverautostart changed to 'true'})
128
+ expect(first_run_result[:native_stdout]).to match(%r{Dsc_xwebsite\[NewWebsite\]/dsc_physicalpath: dsc_physicalpath changed.*to '.+fixtures/website'})
129
+ expect(first_run_result[:native_stdout]).to match(%r{Dsc_xwebsite\[NewWebsite\]/dsc_state: dsc_state changed.*to 'Started'})
130
+ expect(first_run_result[:native_stdout]).to match(%r{Dsc_xwebsite\[NewWebsite\]/dsc_serverautostart: dsc_serverautostart changed.*to 'true'})
131
131
  expect(first_run_result[:native_stdout]).to match(/dsc_xwebsite\[{:name=>"NewWebsite", :dsc_name=>"Puppet DSC Site"}\]: Creating: Finished/)
132
132
  # Run finished
133
133
  expect(first_run_result[:native_stdout]).to match(/Applied catalog/)
@@ -20,6 +20,9 @@ RSpec.describe Puppet::Provider::DscBaseProvider do
20
20
  provider.instance_variable_set(:@cached_query_results, [])
21
21
  provider.instance_variable_set(:@cached_test_results, [])
22
22
  provider.instance_variable_set(:@logon_failures, [])
23
+ provider.instance_variable_set(:@cached_canonicalize_results, [])
24
+ provider.instance_variable_set(:@cached_fresh_get_results, {})
25
+ provider.instance_variable_set(:@insync_property_cache, {})
23
26
  end
24
27
 
25
28
  describe '.initialize' do
@@ -43,6 +46,18 @@ RSpec.describe Puppet::Provider::DscBaseProvider do
43
46
  it 'initializes the logon_failures instance variable' do
44
47
  expect(provider.instance_variable_get(:@logon_failures)).to eq([])
45
48
  end
49
+
50
+ it 'initializes the cached_canonicalize_results instance variable' do
51
+ expect(provider.instance_variable_get(:@cached_canonicalize_results)).to eq([])
52
+ end
53
+
54
+ it 'initializes the cached_fresh_get_results instance variable' do
55
+ expect(provider.instance_variable_get(:@cached_fresh_get_results)).to eq({})
56
+ end
57
+
58
+ it 'initializes the insync_property_cache instance variable' do
59
+ expect(provider.instance_variable_get(:@insync_property_cache)).to eq({})
60
+ end
46
61
  end
47
62
 
48
63
  describe '.cached_test_results' do
@@ -391,36 +406,259 @@ RSpec.describe Puppet::Provider::DscBaseProvider do
391
406
  end
392
407
 
393
408
  describe '.insync?' do
394
- let(:name) { { name: 'foo' } }
395
- let(:attribute_name) { :foo }
396
- let(:is_hash) { { name: 'foo', foo: 1 } }
397
- let(:cached_test_result) { [{ name: 'foo', in_desired_state: true }] }
398
- let(:should_hash_validate_by_property) { { name: 'foo', foo: 1, validation_mode: 'property' } }
399
- let(:should_hash_validate_by_resource) { { name: 'foo', foo: 1, validation_mode: 'resource' } }
409
+ let(:name) { { name: 'foo' } }
410
+ let(:property_name) { :dsc_setting }
411
+ let(:is_hash) { { name: 'foo', dsc_setting: 'Bar' } }
412
+ let(:should_hash) { { name: 'foo', dsc_setting: 'Foo', validation_mode: 'property' } }
413
+ let(:fresh_state) { { name: 'foo', dsc_setting: 'Bar' } }
414
+
415
+ before do
416
+ allow(context).to receive(:debug)
417
+ end
418
+
419
+ context 'when the corrective check is detected' do
420
+ it 'returns nil and deletes the cache entry on second call for the same property' do
421
+ provider.instance_variable_set(:@insync_property_cache, { 'foo_dsc_setting' => true })
422
+ result = provider.send(:insync?, context, name, property_name, is_hash, should_hash)
423
+ expect(result).to be_nil
424
+ expect(provider.instance_variable_get(:@insync_property_cache)).not_to have_key('foo_dsc_setting')
425
+ end
426
+ end
400
427
 
401
428
  context 'when the validation_mode is "resource"' do
402
- it 'calls invoke_test_method if the result of a test is not already cached' do
403
- expect(provider).to receive(:fetch_cached_hashes).and_return([])
404
- expect(provider).to receive(:invoke_test_method).and_return(true)
405
- expect(provider.send(:insync?, context, name, attribute_name, is_hash, should_hash_validate_by_resource)).to be true
429
+ let(:should_hash) { { name: 'foo', dsc_setting: 'Foo', validation_mode: 'resource' } }
430
+
431
+ context 'when DSC Test says in sync' do
432
+ it 'returns true for any property' do
433
+ allow(provider).to receive(:fetch_cached_hashes).and_return([])
434
+ allow(provider).to receive(:invoke_test_method).and_return(true)
435
+ expect(provider.send(:insync?, context, name, property_name, is_hash, should_hash)).to be true
436
+ end
437
+
438
+ it 'caches the Test result and reuses on subsequent calls' do
439
+ cached_test_result = [{ name: 'foo', in_desired_state: true }]
440
+ allow(provider).to receive(:fetch_cached_hashes).and_return(cached_test_result)
441
+ expect(provider).not_to receive(:invoke_test_method)
442
+ expect(provider.send(:insync?, context, name, property_name, is_hash, should_hash)).to be true
443
+ end
406
444
  end
407
445
 
408
- it 'does not call invoke_test_method if the result of a test is already cached' do
409
- expect(provider).to receive(:fetch_cached_hashes).and_return(cached_test_result)
410
- expect(provider).not_to receive(:invoke_test_method)
411
- expect(provider.send(:insync?, context, name, attribute_name, is_hash, should_hash_validate_by_resource)).to be true
446
+ context 'when DSC Test says out of sync' do
447
+ before do
448
+ allow(provider).to receive(:fetch_cached_hashes).and_return([])
449
+ allow(provider).to receive(:invoke_test_method).and_return([false, 'not in desired state'])
450
+ end
451
+
452
+ it 'returns fresh Get comparison result for dsc_ properties' do
453
+ allow(provider).to receive(:get_cached_fresh_state).and_return(fresh_state)
454
+ result = provider.send(:insync?, context, name, property_name, is_hash, should_hash)
455
+ # fresh_state has 'Bar', should_hash has 'Foo' — values differ
456
+ expect(result).to be_an(Array)
457
+ expect(result.first).to be false
458
+ expect(result.last).to match(/dsc_setting changed/)
459
+ end
460
+
461
+ it 'returns true for non-dsc_ properties (suppressed)' do
462
+ result = provider.send(:insync?, context, name, :name, is_hash, should_hash)
463
+ expect(result).to be true
464
+ end
465
+
466
+ it 'returns true when should_value is nil (suppressed)' do
467
+ should_hash_nil = should_hash.merge(dsc_setting: nil)
468
+ result = provider.send(:insync?, context, name, :dsc_setting, is_hash, should_hash_nil)
469
+ expect(result).to be true
470
+ end
412
471
  end
413
472
  end
414
473
 
415
474
  context 'when the validation_mode is "property"' do
416
- it 'does not call invoke_test_method and returns nil' do
417
- expect(provider).not_to receive(:fetch_cached_hashes)
418
- expect(provider).not_to receive(:invoke_test_method)
419
- expect(provider.send(:insync?, context, name, attribute_name, is_hash, should_hash_validate_by_property)).to be_nil
475
+ context 'when property_name does not start with dsc_' do
476
+ it 'returns nil for :name' do
477
+ result = provider.send(:insync?, context, name, :name, is_hash, should_hash)
478
+ expect(result).to be_nil
479
+ end
480
+
481
+ it 'returns nil for :ensure' do
482
+ result = provider.send(:insync?, context, name, :ensure, is_hash, should_hash)
483
+ expect(result).to be_nil
484
+ end
485
+ end
486
+
487
+ context 'when should_value is nil' do
488
+ it 'returns nil' do
489
+ should_hash_nil = should_hash.merge(dsc_setting: nil)
490
+ result = provider.send(:insync?, context, name, :dsc_setting, is_hash, should_hash_nil)
491
+ expect(result).to be_nil
492
+ end
493
+ end
494
+
495
+ context 'when should_value is empty string' do
496
+ it 'returns nil' do
497
+ should_hash_empty = should_hash.merge(dsc_setting: '')
498
+ result = provider.send(:insync?, context, name, :dsc_setting, is_hash, should_hash_empty)
499
+ expect(result).to be_nil
500
+ end
501
+ end
502
+
503
+ context 'when fresh Get returns nil' do
504
+ it 'returns nil' do
505
+ allow(provider).to receive(:get_cached_fresh_state).and_return(nil)
506
+ result = provider.send(:insync?, context, name, :dsc_setting, is_hash, should_hash)
507
+ expect(result).to be_nil
508
+ end
509
+ end
510
+
511
+ context 'when fresh Get returns nil in resource mode' do
512
+ let(:should_hash_resource) { { name: 'foo', dsc_setting: 'Foo', validation_mode: 'resource' } }
513
+
514
+ it 'returns [false, msg] and sets insync_property_cache' do
515
+ allow(provider).to receive(:fetch_cached_hashes).and_return([])
516
+ allow(provider).to receive(:invoke_test_method).and_return([false, 'not in desired state'])
517
+ allow(provider).to receive(:get_cached_fresh_state).and_return(nil)
518
+ result = provider.send(:insync?, context, name, :dsc_setting, is_hash, should_hash_resource)
519
+ expect(result).to be_an(Array)
520
+ expect(result.first).to be false
521
+ expect(provider.instance_variable_get(:@insync_property_cache)).to have_key('foo_dsc_setting')
522
+ end
523
+ end
524
+
525
+ context 'when values match' do
526
+ it 'returns true' do
527
+ matching_fresh = { name: 'foo', dsc_setting: 'Foo' }
528
+ allow(provider).to receive(:get_cached_fresh_state).and_return(matching_fresh)
529
+ result = provider.send(:insync?, context, name, :dsc_setting, is_hash, should_hash)
530
+ expect(result).to be true
531
+ end
532
+ end
533
+
534
+ context 'when values differ' do
535
+ it 'returns [false, change_message] and sets insync_property_cache' do
536
+ differing_fresh = { name: 'foo', dsc_setting: 'Bar' }
537
+ allow(provider).to receive(:get_cached_fresh_state).and_return(differing_fresh)
538
+ result = provider.send(:insync?, context, name, :dsc_setting, is_hash, should_hash)
539
+ expect(result).to be_an(Array)
540
+ expect(result.first).to be false
541
+ expect(result.last).to eq("dsc_setting changed 'Bar' to 'Foo'")
542
+ expect(provider.instance_variable_get(:@insync_property_cache)).to have_key('foo_dsc_setting')
543
+ end
420
544
  end
421
545
  end
422
546
  end
423
547
 
548
+ describe '.values_equal?' do
549
+ it 'returns true for identical values' do
550
+ expect(provider.values_equal?('foo', 'foo')).to be true
551
+ end
552
+
553
+ it 'returns true for nil/nil' do
554
+ expect(provider.values_equal?(nil, nil)).to be true
555
+ end
556
+
557
+ it 'returns true for nil and empty string' do
558
+ expect(provider.values_equal?(nil, '')).to be true
559
+ expect(provider.values_equal?('', nil)).to be true
560
+ end
561
+
562
+ it 'returns true for case-insensitive string match' do
563
+ expect(provider.values_equal?('Foo', 'foo')).to be true
564
+ end
565
+
566
+ it 'returns false for integer vs string mismatch' do
567
+ expect(provider.values_equal?(30, '30')).to be false
568
+ end
569
+
570
+ it 'returns true for arrays in different order' do
571
+ expect(provider.values_equal?(%w[a b c], %w[c b a])).to be true
572
+ end
573
+
574
+ it 'returns true for hashes with case-different keys/values' do
575
+ expect(provider.values_equal?({ 'Foo' => 'Bar' }, { 'foo' => 'bar' })).to be true
576
+ end
577
+
578
+ it 'returns false for genuinely different values' do
579
+ expect(provider.values_equal?('foo', 'bar')).to be false
580
+ end
581
+
582
+ it 'returns false for different numbers' do
583
+ expect(provider.values_equal?(30, 42)).to be false
584
+ end
585
+ end
586
+
587
+ describe '.perform_fresh_get' do
588
+ let(:name) { { name: 'foo', dsc_name: 'foo' } }
589
+ let(:should_hash) { { name: 'foo', dsc_name: 'foo', dsc_setting: 'desired' } }
590
+ let(:attributes) { { name: { type: 'String' }, dsc_name: { type: 'String', mandatory_for_get: true }, dsc_setting: { type: 'String' } } }
591
+
592
+ before do
593
+ allow(context).to receive(:debug)
594
+ allow(context).to receive(:type).and_return(type)
595
+ allow(type).to receive(:attributes).and_return(attributes)
596
+ allow(provider).to receive(:mandatory_get_attributes).and_return([:dsc_name])
597
+ end
598
+
599
+ context 'when invoke_dsc_resource returns data' do
600
+ it 'returns a hash with dsc_ prefixed keys and :name' do
601
+ allow(provider).to receive(:invoke_dsc_resource).and_return({ 'Name' => 'foo', 'Setting' => 'current' })
602
+ result = provider.perform_fresh_get(context, name, should_hash)
603
+ expect(result[:dsc_setting]).to eq('current')
604
+ expect(result[:name]).to eq('foo')
605
+ end
606
+
607
+ it 'sanitizes System.Object[] artifacts to nil' do
608
+ allow(provider).to receive(:invoke_dsc_resource).and_return({ 'Name' => 'foo', 'Setting' => 'System.Object[]' })
609
+ result = provider.perform_fresh_get(context, name, should_hash)
610
+ expect(result[:dsc_setting]).to be_nil
611
+ end
612
+
613
+ it 'normalizes CIM instance hash keys and munges cim_instance_type' do
614
+ allow(provider).to receive(:invoke_dsc_resource).and_return({ 'Name' => 'foo', 'Setting' => { 'SomeKey' => 'val' } })
615
+ result = provider.perform_fresh_get(context, name, should_hash)
616
+ expect(result[:dsc_setting].keys.first).to eq('somekey')
617
+ end
618
+ end
619
+
620
+ context 'when invoke_dsc_resource returns nil' do
621
+ it 'returns nil' do
622
+ allow(provider).to receive(:invoke_dsc_resource).and_return(nil)
623
+ expect(provider.perform_fresh_get(context, name, should_hash)).to be_nil
624
+ end
625
+ end
626
+
627
+ context 'when invoke_dsc_resource raises an error' do
628
+ it 'returns nil and logs a debug message' do
629
+ allow(provider).to receive(:invoke_dsc_resource).and_raise(StandardError, 'boom')
630
+ expect(context).to receive(:debug).with(/perform_fresh_get failed/)
631
+ expect(provider.perform_fresh_get(context, name, should_hash)).to be_nil
632
+ end
633
+ end
634
+ end
635
+
636
+ describe '.get_cached_fresh_state' do
637
+ let(:name) { { name: 'foo' } }
638
+ let(:should_hash) { { name: 'foo', dsc_setting: 'desired' } }
639
+ let(:fresh_result) { { name: 'foo', dsc_setting: 'current' } }
640
+
641
+ before do
642
+ allow(context).to receive(:debug)
643
+ end
644
+
645
+ it 'calls perform_fresh_get on first call and caches the result' do
646
+ expect(provider).to receive(:perform_fresh_get).once.and_return(fresh_result)
647
+ result1 = provider.get_cached_fresh_state(context, name, should_hash)
648
+ result2 = provider.get_cached_fresh_state(context, name, should_hash)
649
+ expect(result1).to eq(fresh_result)
650
+ expect(result2).to eq(fresh_result)
651
+ end
652
+
653
+ it 'caches nil results to avoid repeated failed calls' do
654
+ expect(provider).to receive(:perform_fresh_get).once.and_return(nil)
655
+ result1 = provider.get_cached_fresh_state(context, name, should_hash)
656
+ result2 = provider.get_cached_fresh_state(context, name, should_hash)
657
+ expect(result1).to be_nil
658
+ expect(result2).to be_nil
659
+ end
660
+ end
661
+
424
662
  describe '.invoke_get_method' do
425
663
  subject(:result) { provider.invoke_get_method(context, name_hash) }
426
664
 
@@ -656,6 +894,47 @@ RSpec.describe Puppet::Provider::DscBaseProvider do
656
894
  end
657
895
  end
658
896
 
897
+ context 'when DSC returns System.Object[] string values' do
898
+ let(:parsed_invocation_data) do
899
+ {
900
+ 'Name' => 'foo',
901
+ 'Ensure' => 'System.Object[]',
902
+ 'Time' => nil
903
+ }
904
+ end
905
+
906
+ before do
907
+ allow(ps_manager).to receive(:execute).with(script, nil).and_return({ stdout: 'DSC Data' })
908
+ allow(JSON).to receive(:parse).with('DSC Data').and_return(parsed_invocation_data)
909
+ allow(provider).to receive(:fetch_cached_hashes).and_return([])
910
+ end
911
+
912
+ it 'sanitizes System.Object[] to nil' do
913
+ expect(result[:dsc_ensure]).to be_nil
914
+ end
915
+ end
916
+
917
+ context 'when DSC returns nil for a namevar' do
918
+ let(:parsed_invocation_data) do
919
+ {
920
+ 'Name' => nil,
921
+ 'Ensure' => 'Present'
922
+ }
923
+ end
924
+
925
+ before do
926
+ allow(ps_manager).to receive(:execute).with(script, nil).and_return({ stdout: 'DSC Data' })
927
+ allow(JSON).to receive(:parse).with('DSC Data').and_return(parsed_invocation_data)
928
+ allow(provider).to receive(:fetch_cached_hashes).and_return([])
929
+ allow(provider).to receive(:namevar_attributes).and_return(%i[name dsc_name])
930
+ end
931
+
932
+ it 'preserves the namevar value from the input name_hash' do
933
+ expect(result[:dsc_name]).to eq('foo')
934
+ expect(result[:name]).to eq('foo')
935
+ end
936
+ end
937
+
659
938
  context 'when the DSC invocation errors' do
660
939
  it 'writes an error and returns nil' do
661
940
  expect(provider).not_to receive(:logon_failed_already?)
@@ -864,6 +1143,44 @@ RSpec.describe Puppet::Provider::DscBaseProvider do
864
1143
  end
865
1144
  end
866
1145
 
1146
+ describe '.invoke_get_method_for_canonicalize' do
1147
+ before do
1148
+ allow(context).to receive(:debug)
1149
+ allow(provider).to receive(:namevar_attributes).and_return(%i[name dsc_name])
1150
+ end
1151
+
1152
+ it 'calls invoke_get_method and caches to @cached_canonicalize_results' do
1153
+ result = { name: 'foo', dsc_name: 'foo', dsc_setting: 'bar' }
1154
+ allow(provider).to receive(:invoke_get_method).and_return(result)
1155
+ allow(provider).to receive(:fetch_cached_hashes).and_return([])
1156
+
1157
+ provider.invoke_get_method_for_canonicalize(context, { name: 'foo', dsc_name: 'foo' })
1158
+ expect(provider.instance_variable_get(:@cached_canonicalize_results)).to include(result)
1159
+ end
1160
+
1161
+ it 'removes entries from @cached_query_results so get() does its own lookup' do
1162
+ result = { name: 'foo', dsc_name: 'foo', dsc_setting: 'bar' }
1163
+ cached_entry = result.dup
1164
+ allow(provider).to receive(:invoke_get_method).and_return(result)
1165
+ # First call checks @cached_canonicalize_results — return [] (cache miss)
1166
+ # Subsequent calls within reject! should return a match so the entry gets removed
1167
+ allow(provider).to receive(:fetch_cached_hashes).and_return([], [cached_entry], [])
1168
+ provider.instance_variable_set(:@cached_query_results, [cached_entry])
1169
+
1170
+ provider.invoke_get_method_for_canonicalize(context, { name: 'foo', dsc_name: 'foo' })
1171
+ expect(provider.instance_variable_get(:@cached_query_results)).to be_empty
1172
+ end
1173
+
1174
+ it 'returns cached result on subsequent calls' do
1175
+ result = { name: 'foo', dsc_name: 'foo', dsc_setting: 'bar' }
1176
+ provider.instance_variable_set(:@cached_canonicalize_results, [result])
1177
+ allow(provider).to receive(:fetch_cached_hashes).with([result], anything).and_return([result])
1178
+
1179
+ expect(provider).not_to receive(:invoke_get_method)
1180
+ expect(provider.invoke_get_method_for_canonicalize(context, { name: 'foo', dsc_name: 'foo' })).to eq(result)
1181
+ end
1182
+ end
1183
+
867
1184
  describe '.puppetize_name' do
868
1185
  it 'downcases the input string' do
869
1186
  expect(provider.puppetize_name('FooBar')).to eq('foobar')
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby-pwsh
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.0.0
4
+ version: 2.0.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Puppet, Inc.
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2025-05-06 00:00:00.000000000 Z
11
+ date: 2026-02-16 00:00:00.000000000 Z
12
12
  dependencies: []
13
13
  description: PowerShell code manager for ruby.
14
14
  email:
@@ -67,7 +67,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
67
67
  - !ruby/object:Gem::Version
68
68
  version: '0'
69
69
  requirements: []
70
- rubygems_version: 3.3.27
70
+ rubygems_version: 3.4.19
71
71
  signing_key:
72
72
  specification_version: 4
73
73
  summary: PowerShell code manager for ruby.