Class: Puppet::Provider::DscBaseProvider

Inherits:
Object
  • Object
show all
Defined in:
lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb

Overview

rubocop:disable Metrics/ClassLength

Constant Summary collapse

SECRET_POSTFIX =

In order to avoid having to update the string that indicates when a value came from a sensitive string in multiple places, use a constant to indicate what the text of the secret identifier should be. This is used to write, identify, and redact secrets between PowerShell & Puppet.

'#PuppetSensitive'
SECRET_DATA_REGEX =

With multiple methods which need to discover secrets it is necessary to keep a single regex which can discover them. This will lazily match everything in a single-quoted string which ends with the secret postfix id and mark the actual contents of the string as the secret.

/'(?<secret>[^']+)+?#{Regexp.quote(SECRET_POSTFIX)}'/

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initializeDscBaseProvider

Initializes the provider, preparing the instance variables which cache:

  • the canonicalized resources across calls

  • query results

  • logon failures



13
14
15
16
17
18
19
20
21
22
23
# File 'lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb', line 13

def initialize
  @cached_canonicalized_resource = []
  @cached_canonicalize_results = [] # Cache for invoke_get_method calls from canonicalize only
  @cached_query_results = []        # Cache for invoke_get_method calls from get only
  @cached_test_results = []
  @cached_fresh_get_results = {}
  @insync_property_cache = {}
  @logon_failures = []
  @timeout = nil # default timeout, ps_manager.execute is expecting nil by default..
  super
end

Instance Attribute Details

#cached_test_resultsObject (readonly)

Returns the value of attribute cached_test_results.



25
26
27
# File 'lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb', line 25

def cached_test_results
  @cached_test_results
end

Instance Method Details

#canonicalize(context, resources) ⇒ Hash

Implements the canonicalize feature of the Resource API; this method is called first against any resources defined in the manifest, then again to conform the results from a get call. The method attempts to retrieve the DSC resource from the machine; if the resource is found, this method then compares the downcased values of the two hashes, overwriting the manifest value with the discovered one if they are case insensitively equivalent; this enables case insensitive but preserving behavior where a manifest declaration of a path as “c:/foo/bar” if discovered on disk as “C:FooBar” will canonicalize to the latter and prevent any flapping.

rubocop:disable Metrics/BlockLength, Metrics/MethodLength

Parameters:

  • the Puppet runtime context to operate in and send feedback to

  • the hash of the resource to canonicalize from either manifest or invocation

Returns:

  • returns a hash representing the current state of the object, if it exists



52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
# File 'lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb', line 52

def canonicalize(context, resources)
  canonicalized_resources = []
  resources.collect do |r|
    # During RSAPI refresh runs mandatory parameters are stripped and not available;
    # Instead of checking again and failing, search the cache for a namevar match.
    namevarized_r = r.select { |k, _v| namevar_attributes(context).include?(k) }
    cached_result = fetch_cached_hashes(@cached_canonicalized_resource, [namevarized_r]).first
    if cached_result.nil?
      # If the resource is meant to be absent, skip canonicalization and rely on the manifest
      # value; there's no reason to compare system state to desired state for casing if the
      # resource is being removed.
      if r[:dsc_ensure] == 'absent'
        canonicalized = r.dup
        @cached_canonicalized_resource << r.dup
      else
        # Use a separate cache for canonicalize's Get calls so we don't pollute
        # @cached_query_results, which get() uses for the "is" state comparison.
        # Sharing a cache between canonicalize and get caused the Resource API to
        # see identical "should" and "is" values, killing per-property report detail.
        canonicalized = invoke_get_method_for_canonicalize(context, r)
        # If the resource could not be found or was returned as absent, skip case munging and
        # treat the manifest values as canonical since the resource is being created.
        # rubocop:disable Metrics/BlockNesting
        if canonicalized.nil? || canonicalized[:dsc_ensure] == 'absent'
          canonicalized = r.dup
          @cached_canonicalized_resource << r.dup
        else
          parameters = r.select { |name, _properties| parameter_attributes(context).include?(name) }
          canonicalized.merge!(parameters)
          canonicalized[:name] = r[:name]
          if r[:dsc_psdscrunascredential].nil?
            canonicalized.delete(:dsc_psdscrunascredential)
          else
            canonicalized[:dsc_psdscrunascredential] = r[:dsc_psdscrunascredential]
          end
          downcased_result = recursively_downcase(canonicalized)
          downcased_resource = recursively_downcase(r)
          # Ensure that metaparameters are preserved when we canonicalize the resource.
          metaparams = r.select { |key, _value| Puppet::Type.metaparam?(key) }
          canonicalized.merge!(metaparams) unless metaparams.nil?
          downcased_result.each do |key, value|
            # Canonicalize to the manifest value unless the downcased strings match and the attribute is not an enum:
            # - When the values don't match at all, the manifest value is desired;
            # - When the values match case insensitively but the attribute is an enum, and the casing from invoke_get_method
            #   is not int the enum, prefer the casing of the manifest enum.
            # - When the values match case insensitively and the attribute is not an enum, or is an enum and invoke_get_method casing
            #   is in the enum, prefer the casing from invoke_get_method
            is_enum = enum_attributes(context).include?(key)
            canonicalized_value_in_enum = if is_enum
                                            enum_values(context, key).include?(canonicalized[key])
                                          else
                                            false
                                          end
            match_insensitively = same?(value, downcased_resource[key])
            canonicalized[key] = r[key] unless match_insensitively && (canonicalized_value_in_enum || !is_enum)
            canonicalized.delete(key) unless downcased_resource.key?(key)
          end
          # Cache the actually canonicalized resource separately
          @cached_canonicalized_resource << canonicalized.dup
        end
        # rubocop:enable Metrics/BlockNesting
      end
    else
      # The resource has already been canonicalized for the set values and is not being canonicalized for get
      # In this case, we do *not* want to process anything, just return the resource. We only call canonicalize
      # so we can get case insensitive but preserving values for _setting_ state.
      canonicalized = r
    end
    canonicalized_resources << canonicalized
  end
  context.debug("Canonicalized Resources: #{canonicalized_resources}")
  canonicalized_resources
end

#canonicalize_get_value!(context, data, type_key, query_props) ⇒ Object

Canonicalize a single value returned by DSC Get: handle CIM instances, DateTime, and empty arrays.



679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
# File 'lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb', line 679

def canonicalize_get_value!(context, data, type_key, query_props)
  # Special handling for CIM Instances
  if data[type_key].is_a?(Enumerable)
    downcase_hash_keys!(data[type_key])
    munge_cim_instances!(data[type_key])
  end

  # Convert DateTime back to appropriate type
  if context.type.attributes[type_key][:mof_type] =~ /DateTime/i && !data[type_key].nil?
    data[type_key] = begin
      Puppet::Pops::Time::Timestamp.parse(data[type_key])
    rescue ArgumentError, TypeError => e
      context.err("Value returned for DateTime (#{data[type_key].inspect}) failed to parse: #{e}")
      nil
    end
  end
  # PowerShell does not distinguish between a return of empty array/string
  #  and null but Puppet does; revert to those values if specified.
  data[type_key] = [] if data[type_key].nil? && query_props.key?(type_key) && query_props[type_key].is_a?(Array)
end

#clear_instantiated_variables!Object

Clear the instantiated variables hash to be ready for the next run



867
868
869
# File 'lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb', line 867

def clear_instantiated_variables!
  @instantiated_variables = {}
end

#create(context, name, should) ⇒ Hash

Attempts to set an instance of the DSC resource, invoking the Set method and thinly wrapping the invoke_set_method method; whether this method, update, or delete is called is entirely up to the Resource API based on the results

Parameters:

  • the Puppet runtime context to operate in and send feedback to

  • the name of the resource being created

Returns:

  • 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.



353
354
355
356
# File 'lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb', line 353

def create(context, name, should)
  context.debug("Creating '#{name}' with #{should.inspect}")
  invoke_set_method(context, name, should)
end

#delete(context, name) ⇒ Hash

Attempts to set an instance of the DSC resource, invoking the Set method and thinly wrapping the invoke_set_method method; whether this method, create, or update is called is entirely up to the Resource API based on the results

Parameters:

  • the Puppet runtime context to operate in and send feedback to

  • the name of the resource being created

Returns:

  • 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.



377
378
379
380
# File 'lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb', line 377

def delete(context, name)
  context.debug("Deleting '#{name}'")
  invoke_set_method(context, name, name.merge({ dsc_ensure: 'Absent' }))
end

#downcase_hash_keys!(enumerable) ⇒ Object

Recursively transforms any enumerable, downcasing any hash keys it finds, changing the passed enumerable.

Parameters:

  • a string, array, hash, or other object to attempt to recursively downcase



885
886
887
888
889
890
891
892
893
894
895
# File 'lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb', line 885

def downcase_hash_keys!(enumerable)
  if enumerable.is_a?(Hash)
    enumerable.keys.each do |key|
      name = key.dup.downcase
      enumerable[name] = enumerable.delete(key)
      downcase_hash_keys!(enumerable[name]) if enumerable[name].is_a?(Enumerable)
    end
  else
    enumerable.each { |item| downcase_hash_keys!(item) if item.is_a?(Enumerable) }
  end
end

#enum_attributes(context) ⇒ Array

Parses the DSC resource type definition to retrieve the names of any attributes which are specified as enums Note that for complex types, especially those that have nested CIM instances, this will return for any data type which includes an Enum, not just for simple Enum[] or Optional[Enum[]] data types.

Parameters:

  • the Puppet runtime context to operate in and send feedback to

Returns:

  • returns an array of attribute names as symbols which are enums



1007
1008
1009
# File 'lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb', line 1007

def enum_attributes(context)
  context.type.attributes.select { |_name, properties| properties[:type].include?('Enum[') }.keys
end

#enum_values(context, attribute) ⇒ Array

Parses the DSC resource type definition to retrieve the values of any attributes which are specified as enums

Parameters:

  • the Puppet runtime context to operate in and send feedback to

  • the enum attribute to retrieve the allowed values from

Returns:

  • returns an array of attribute names as symbols which are enums



1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
# File 'lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb', line 1016

def enum_values(context, attribute)
  # Get the attribute's type string for the given key
  type_string = context.type.attributes[attribute][:type]

  # Return an empty array if the key doesn't have an Enum type or doesn't exist
  return [] unless type_string&.include?('Enum[')

  # Extract the enum values from the type string
  enum_content = type_string.match(/Enum\[(.*?)\]/)&.[](1)

  # Return an empty array if we couldn't find the enum values
  return [] if enum_content.nil?

  # Return an array of the enum values, stripped of extra whitespace and quote marks
  enum_content.split(',').map { |val| val.strip.delete('\'') }
end

#escape_quotes(text) ⇒ String

Escape any nested single quotes in a Sensitive string

Parameters:

  • the text to escape

Returns:

  • the escaped text



1340
1341
1342
# File 'lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb', line 1340

def escape_quotes(text)
  text.gsub("'", "''")
end

#fetch_cached_hashes(cache, hashes) ⇒ Array

Look through a cache to retrieve the hashes specified, if they have been cached. Does so by seeing if each of the specified hashes is a subset of any of the hashes in the cache, so 1, bar: 2 would return if 1 was the search hash.

Parameters:

  • the instance variable containing cached hashes to search through

  • the list of hashes to search the cache for

Returns:

  • an array containing the matching hashes for the search condition, if any



34
35
36
37
38
39
# File 'lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb', line 34

def fetch_cached_hashes(cache, hashes)
  cache.reject do |item|
    matching_hash = hashes.select { |hash| (item.to_a - hash.to_a).empty? || (hash.to_a - item.to_a).empty? }
    matching_hash.empty?
  end.flatten
end

#format(value) ⇒ String

Convert a Puppet/Ruby value into a PowerShell representation. Requires some slight additional munging over what is provided in the ruby-pwsh library, as it does not handle unwrapping Sensitive data types or interpolating Credentials.

Parameters:

  • The object to format into valid PowerShell

Returns:

  • A string representation of the input value as valid PowerShell



1277
1278
1279
1280
1281
1282
1283
# File 'lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb', line 1277

def format(value)
  Pwsh::Util.format_powershell_value(value)
rescue RuntimeError => e
  raise unless e.message.include?('Sensitive [value redacted]')

  Pwsh::Util.format_powershell_value(unwrap(value))
end

#format_ciminstance(variable_name, class_name, property_hash) ⇒ String

Write a line of PowerShell which creates a CIM Instance and assigns it to a variable

Parameters:

  • the name of the Variable to assign the CIM Instance to

  • the CIM Class to instantiate

  • the Properties which define the CIM Instance

Returns:

  • A line of PowerShell which defines the CIM Instance and stores it to a variable



1172
1173
1174
1175
1176
1177
1178
1179
1180
# File 'lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb', line 1172

def format_ciminstance(variable_name, class_name, property_hash)
  definition = "$#{variable_name} = New-CimInstance -ClientOnly -ClassName '#{class_name}' -Property #{format(property_hash)}"
  # AWFUL HACK to make New-CimInstance happy ; it can't parse an array unless it's an array of Cim Instances
  # definition = definition.gsub("@(@{'cim_instance_type'","[CimInstance[]]@(@{'cim_instance_type'")
  # EVEN WORSE HACK - this one we can't even be sure it's a cim instance...
  # but I don't _think_ anything but nested cim instances show up as hashes inside an array
  definition = definition.gsub('@(@{', '[CimInstance[]]@(@{')
  interpolate_variables(definition)
end

#format_pscredential(variable_name, credential_hash) ⇒ String

Write a line of PowerShell which creates a PSCredential object and assigns it to a variable

Parameters:

  • the name of the Variable to assign the PSCredential object to

  • the Properties which define the PSCredential Object

Returns:

  • A line of PowerShell which defines the PSCredential object and stores it to a variable



1097
1098
1099
# File 'lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb', line 1097

def format_pscredential(variable_name, credential_hash)
  "$#{variable_name} = New-PSCredential -User #{credential_hash['user']} -Password '#{credential_hash['password']}#{SECRET_POSTFIX}'"
end

#get(context, names = nil) ⇒ Hash

Attempts to retrieve an instance of the DSC resource, invoking the Get method and passing any namevars as the Properties to Invoke-DscResource. The result object, if any, is compared to the specified properties in the Puppet Resource to decide whether it needs to be created, updated, deleted, or whether it is in the desired state.

Parameters:

  • the Puppet runtime context to operate in and send feedback to

  • (defaults to: nil)

    the hash of namevar properties and their values to use to get the resource

Returns:

  • returns a hash representing the current state of the object, if it exists



135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
# File 'lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb', line 135

def get(context, names = nil)
  # Relies on the get_simple_filter feature to pass the namevars
  # as an array containing the namevar parameters as a hash.
  # This hash is functionally the same as a should hash as
  # passed to the invocable_resource method.
  context.debug('Collecting data from the DSC Resource')

  # If the resource has already been queried, do not bother querying for it again
  cached_results = fetch_cached_hashes(@cached_query_results, names)
  return cached_results unless cached_results.empty?

  # Use the raw system state from canonicalize's Get call if available.
  # @cached_canonicalize_results has the unmodified DSC Get response (before
  # canonicalize normalized casing). This gives the Resource API real "is" data
  # with all property values, enabling per-property "Changed from" reporting.
  unless @cached_canonicalize_results.empty?
    canon_results = fetch_cached_hashes(@cached_canonicalize_results, names)
    unless canon_results.empty?
      # Cache these as query results too so subsequent get() calls find them
      canon_results.each do |r|
        @cached_query_results << r.dup if fetch_cached_hashes(@cached_query_results, [r]).empty?
      end
      return canon_results
    end
  end

  if @cached_canonicalized_resource.empty?
    mandatory_properties = {}
  else
    canonicalized_resource = @cached_canonicalized_resource[0].dup
    mandatory_properties = canonicalized_resource.select do |attribute, _value|
      (mandatory_get_attributes(context) - namevar_attributes(context)).include?(attribute)
    end
    # If dsc_psdscrunascredential was specified, re-add it here.
    mandatory_properties[:dsc_psdscrunascredential] = canonicalized_resource[:dsc_psdscrunascredential] if canonicalized_resource.key?(:dsc_psdscrunascredential)
  end
  names.collect do |name|
    name = { name: name } if name.is_a? String
    invoke_get_method(context, name.merge(mandatory_properties))
  end
end

#get_cached_fresh_state(context, name, should) ⇒ Hash

Caching wrapper around perform_fresh_get — one fresh DSC Get per resource, reused across all properties during that resource’s insync? calls.

Parameters:

  • the Puppet runtime context to operate in and send feedback to

  • the name hash for the resource

  • the desired state hash (used to extract query properties)

Returns:

  • returns a hash with dsc_ prefixed keys and current system values, or nil on failure



335
336
337
338
339
340
341
342
343
344
# File 'lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb', line 335

def get_cached_fresh_state(context, name, should)
  cache_key = name.is_a?(Hash) ? name[:name] : name
  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)}")
  unless @cached_fresh_get_results.key?(cache_key)
    result = perform_fresh_get(context, name, should)
    context.debug("INSYNC_DIAG: perform_fresh_get returned #{result.nil? ? 'nil' : result.keys.inspect}")
    @cached_fresh_get_results[cache_key] = result
  end
  @cached_fresh_get_results[cache_key]
end

#handle_secrets(text, replacement, error_message) ⇒ String

Strings containing sensitive data have a secrets postfix. These strings cannot be passed directly either to debug streams or to PowerShell and must be handled; this method contains the shared logic for parsing text for secrets and substituting values for them.

Parameters:

  • the text to parse and handle for secrets

  • the value to pass to gsub to replace secrets with

  • the error message to raise instead of leaking secrets

Returns:

  • the modified text



1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
# File 'lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb', line 1362

def handle_secrets(text, replacement, error_message)
  # Every secret unwrapped in this module will unwrap as "'secret#{SECRET_POSTFIX}'"
  # Currently, no known resources specify a SecureString instead of a PSCredential object.
  return text unless /#{Regexp.quote(SECRET_POSTFIX)}/.match?(text)

  # In order to reduce time-to-parse, look at each line individually and *only* attempt
  # to substitute if a naive match for the secret postfix is found on the line.
  modified_text = text.split("\n").map do |line|
    if /#{Regexp.quote(SECRET_POSTFIX)}/.match?(line)
      line.gsub(SECRET_DATA_REGEX, replacement)
    else
      line
    end
  end

  modified_text = modified_text.join("\n")

  # Something has gone wrong, error loudly
  raise error_message if /#{Regexp.quote(SECRET_POSTFIX)}/.match?(modified_text)

  modified_text
end

#instantiated_variablesHash

Return a Hash containing all of the variables defined for instantiation as well as the Ruby hash for their properties so they can be matched and replaced as needed.

Returns:

  • containing all instantiated variables and the properties that they define



862
863
864
# File 'lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb', line 862

def instantiated_variables
  @instantiated_variables ||= {}
end

#insync?(context, name, property_name, _is_hash, should_hash) ⇒ Boolean, ...

Determine if the DSC Resource is in the desired state, using fresh DSC Get results to provide accurate property comparison and real change messages.

For validation_mode: resource, delegates entirely to DSC Test (unchanged).

For validation_mode: property (default), performs a fresh DSC Get (one per resource, cached) that bypasses the get()/canonicalize pipeline, then compares each dsc_ property against the desired value. Returns:

  • true if the property matches (suppresses false positive events)

  • false, “‘current’ -> ‘desired’”

    if the property genuinely differs

    (the RSAPI uses the change_message as the Event text in PE)

  • nil to fall through to default RSAPI comparison (non-dsc_ properties, or if the fresh Get failed)

Parameters:

  • the Puppet runtime context to operate in and send feedback to

  • the name of the resource being tested

  • the name of the property being compared

  • the current state of the resource on the system (unused, required by RSAPI signature)

  • the desired state of the resource per the manifest

Returns:

  • returns true/false/[false, message] if the resource is/isn’t in the desired state, or nil to fall through to default property comparison.



528
529
530
531
532
533
534
535
536
537
538
539
540
# File 'lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb', line 528

def insync?(context, name, property_name, _is_hash, should_hash)
  # Detect corrective change check: after insync? returns [false, msg], Puppet calls
  # insync? again for the same property. Return nil so default comparison handles it.
  cache_key = name.is_a?(Hash) ? name[:name] : name
  property_key = "#{cache_key}_#{property_name}"
  return nil if @insync_property_cache.delete(property_key)

  if should_hash[:validation_mode] == 'resource'
    insync_resource_mode(context, name, property_name, should_hash)
  else
    insync_property_mode(context, name, property_name, should_hash)
  end
end

#interpolate_variables(string) ⇒ String

Look through a fully formatted string, replacing all instances where a value matches the formatted properties of an instantiated variable with references to the variable instead. This allows us to pass complex and nested CIM instances to the Invoke-DscResource parameter hash without constructing them in the hash.

Parameters:

  • the string of text to search through for places an instantiated variable can be referenced

Returns:

  • the string with references to instantiated variables instead of their properties



1039
1040
1041
1042
1043
1044
1045
1046
# File 'lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb', line 1039

def interpolate_variables(string)
  modified_string = string
  # Always replace later-created variables first as they sometimes were built from earlier ones
  instantiated_variables.reverse_each do |variable_name, ruby_definition|
    modified_string = modified_string.gsub(format(ruby_definition), "$#{variable_name}")
  end
  modified_string
end

#invocable_resource(should, context, dsc_invoke_method) ⇒ Hash

Converts a Puppet resource hash into a hash with the information needed to call Invoke-DscResource, including the desired state, the path to the PowerShell module containing the resources, the invoke method, and metadata about the DSC Resource and Puppet Type.

Parameters:

  • A hash representing the desired state of the DSC resource as defined in Puppet

  • the Puppet runtime context to operate in and send feedback to

  • the method to pass to Invoke-DscResource: get, set, or test

Returns:

  • a hash with the information needed to run Invoke-DscResource



770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
# File 'lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb', line 770

def invocable_resource(should, context, dsc_invoke_method)
  resource = {}
  resource[:parameters] = {}
  i[name dscmeta_resource_friendly_name dscmeta_resource_name dscmeta_resource_implementation dscmeta_module_name dscmeta_module_version].each do |k|
    resource[k] = context.type.definition[k]
  end
  should.each do |k, v|
    # PSDscRunAsCredential is considered a namevar and will always be passed, even if nil
    # To prevent flapping during runs, remove it from the resource definition unless specified
    next if k == :dsc_psdscrunascredential && v.nil?

    resource[:parameters][k] = {}
    resource[:parameters][k][:value] = v
    i[mof_type mof_is_embedded].each do |ky|
      resource[:parameters][k][ky] = context.type.definition[:attributes][k][ky]
    end
  end
  resource[:dsc_invoke_method] = dsc_invoke_method

  resource[:vendored_modules_path] = vendored_modules_path(resource[:dscmeta_module_name])

  resource[:attributes] = nil

  context.debug("invocable_resource: #{resource.inspect}")
  resource
end

#invoke_dsc_resource(context, name_hash, props, method) ⇒ Hash

Invokes the given DSC method, passing the name_hash as the properties to use with Invoke-DscResource The PowerShell script returns a JSON hash with key-value pairs indicating the result of the given command. The hash is left untouched for the most part with any further parsing handled by the methods that call upon it.

Parameters:

  • the Puppet runtime context to operate in and send feedback to

  • the hash of namevars to be passed as properties to Invoke-DscResource

  • the properties to be passed to Invoke-DscResource

  • the method to be specified

Returns:

  • returns a hash representing the result of the DSC resource call



391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
# File 'lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb', line 391

def invoke_dsc_resource(context, name_hash, props, method)
  # Do not bother running if the logon credentials won't work
  if !name_hash[:dsc_psdscrunascredential].nil? && logon_failed_already?(name_hash[:dsc_psdscrunascredential])
    context.err('Logon credentials are invalid')
    return nil
  end
  specify_dsc_timeout(name_hash)
  resource = invocable_resource(props, context, method)
  script_content = ps_script_content(resource)
  context.debug("Invoke-DSC Timeout: #{@timeout} milliseconds") if @timeout
  context.debug("Script:\n #{redact_secrets(script_content)}")
  output = ps_manager.execute(remove_secret_identifiers(script_content), @timeout)

  if output[:stdout].nil?
    message = 'Nothing returned.'
    message += " #{output[:errormessage]}" if output[:errormessage]&.match?(/PowerShell module timeout \(\d+ ms\) exceeded while executing/)
    context.err(message)
    return nil
  end

  begin
    data = JSON.parse(output[:stdout])
  rescue StandardError => e
    context.err(e)
    return nil
  end
  context.debug("raw data received: #{data.inspect}")
  collision_error_matcher = /The Invoke-DscResource cmdlet is in progress and must return before Invoke-DscResource can be invoked/

  error = data['errormessage']

  unless error.nil? || error.empty?
    # NB: We should have a way to stop processing this resource *now* without blowing up the whole Puppet run
    # Raising an error stops processing but blows things up while context.err alerts but continues to process
    if error.include?('Logon failure: the user has not been granted the requested logon type at this computer')
      logon_error = "PSDscRunAsCredential account specified (#{name_hash[:dsc_psdscrunascredential]['user']}) does not have appropriate logon rights; are they an administrator?"
      name_hash[:name].nil? ? context.err(logon_error) : context.err(name_hash[:name], logon_error)
      @logon_failures << name_hash[:dsc_psdscrunascredential].dup
      # This is a hack to handle the query cache to prevent a second lookup
      @cached_query_results << name_hash # if fetch_cached_hashes(@cached_query_results, [data]).empty?
    elsif error.match?(collision_error_matcher)
      context.notice('Invoke-DscResource collision detected: Please stagger the timing of your Puppet runs as this can lead to unexpected behaviour.')
      retry_invoke_dsc_resource(context, 5, 60, collision_error_matcher) do
        data = ps_manager.execute(remove_secret_identifiers(script_content))[:stdout]
      end
    else
      context.err(error)
    end
    # Either way, something went wrong and we didn't get back a good result, so return nil
    return nil
  end
  data
end

#invoke_get_method(context, name_hash) ⇒ Hash

Invokes the Get method, passing the name_hash as the properties to use with Invoke-DscResource The PowerShell script returns a JSON representation of the DSC Resource’s CIM Instance munged as best it can be for Ruby. Once that JSON is parsed into a hash this method further munges it to fit the expected property definitions. Finally, it returns the object for the Resource API to compare against and determine what future actions, if any, are needed.

Parameters:

  • the Puppet runtime context to operate in and send feedback to

  • the hash of namevars to be passed as properties to Invoke-DscResource

Returns:

  • returns a hash representing the DSC resource munged to the representation the Puppet Type expects



604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
# File 'lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb', line 604

def invoke_get_method(context, name_hash)
  context.debug("retrieving #{name_hash.inspect}")

  query_props = name_hash.select { |k, v| mandatory_get_attributes(context).include?(k) || (k == :dsc_psdscrunascredential && !v.nil?) }
  data = invoke_dsc_resource(context, name_hash, query_props, 'get')
  return nil if data.nil?

  # DSC gives back information we don't care about; filter down to only
  # those properties exposed in the type definition.
  valid_attributes = context.type.attributes.keys.collect(&:to_s)
  parameters = parameter_attributes(context).collect(&:to_s)
  data.select! { |key, _value| valid_attributes.include?("dsc_#{key.downcase}") }
  data.reject! { |key, _value| parameters.include?("dsc_#{key.downcase}") }
  # Canonicalize the results to match the type definition representation;
  # failure to do so will prevent the resource_api from comparing the result
  # to the should hash retrieved from the resource definition in the manifest.
  data.keys.each do |key|
    type_key = :"dsc_#{key.downcase}"
    data[type_key] = data.delete(key)
    canonicalize_get_value!(context, data, type_key, query_props)
  end
  sanitize_dotnet_artifacts!(context, data)
  preserve_namevar_values!(context, data, name_hash)

  data = stringify_nil_attributes(context, data)

  # Have to check for this to avoid a weird canonicalization warning
  # The Resource API calls canonicalize against the current state which
  # will lead to dsc_ensure being set to absent in the name_hash even if
  # it was set to present in the manifest
  name_hash_has_nil_keys = name_hash.count { |_k, v| v.nil? }.positive?
  # We want to throw away all of the empty keys if and only if the manifest
  # declaration is for an absent resource and the resource is actually absent
  data.compact! if data[:dsc_ensure] == 'Absent' && name_hash[:dsc_ensure] == 'Absent' && !name_hash_has_nil_keys

  # Sort the return for order-insensitive nested enumerable comparison:
  data = recursively_sort(data)

  # Cache the query to prevent a second lookup
  @cached_query_results << data.dup if fetch_cached_hashes(@cached_query_results, [data]).empty?
  context.debug("Returned to Puppet as #{data}")
  data
end

#invoke_get_method_for_canonicalize(context, name_hash) ⇒ Hash

Invokes the Get method for canonicalize only, using a separate cache from get(). This prevents canonicalize from polluting @cached_query_results, which get() relies on to return the “is” state for the Resource API’s property-by-property comparison.

Parameters:

  • the Puppet runtime context to operate in and send feedback to

  • the hash of namevars to be passed as properties to Invoke-DscResource

Returns:

  • returns a hash representing the DSC resource munged to the representation the Puppet Type expects



655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
# File 'lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb', line 655

def invoke_get_method_for_canonicalize(context, name_hash)
  # Check the canonicalize-specific cache first
  cached = fetch_cached_hashes(@cached_canonicalize_results, [name_hash.select { |k, _v| namevar_attributes(context).include?(k) }])
  return cached.first unless cached.empty?

  # Call invoke_get_method which will do the actual DSC Get call.
  # It will also cache to @cached_query_results as a side effect — remove that entry
  # so get() is forced to do its own fresh lookup later.
  result = invoke_get_method(context, name_hash)

  # Remove the entry that invoke_get_method just added to @cached_query_results
  # so that get() will do its own fresh DSC Get call and produce independent "is" data.
  @cached_query_results.select! do |item|
    matching = fetch_cached_hashes([item], [name_hash.select { |k, _v| namevar_attributes(context).include?(k) }])
    matching.empty?
  end

  # Cache in our own separate cache
  @cached_canonicalize_results << result.dup if result && fetch_cached_hashes(@cached_canonicalize_results, [result]).empty?

  result
end

#invoke_params(resource) ⇒ String

Munge a resource definition (as from invocable_resource) into valid PowerShell which represents the InvokeParams hash which will be splatted to Invoke-DscResource, interpolating all previously defined variables into the hash.

Parameters:

  • a hash with the information needed to run Invoke-DscResource

Returns:

  • A string representing the PowerShell definition of the InvokeParams hash



1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
# File 'lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb', line 1188

def invoke_params(resource) # rubocop:disable Metrics/MethodLength
  params = {
    Name: resource[:dscmeta_resource_friendly_name],
    Method: resource[:dsc_invoke_method],
    Property: {}
  }
  if resource.key?(:dscmeta_module_version)
    params[:ModuleName] = {}
    params[:ModuleName][:ModuleName] = if resource[:dscmeta_resource_implementation] == 'Class'
                                         resource[:dscmeta_module_name]
                                       else
                                         "#{resource[:vendored_modules_path]}/#{resource[:dscmeta_module_name]}/#{resource[:dscmeta_module_name]}.psd1"
                                       end
    params[:ModuleName][:RequiredVersion] = resource[:dscmeta_module_version]
  else
    params[:ModuleName] = resource[:dscmeta_module_name]
  end
  resource[:parameters].each do |property_name, property_hash|
    # ignore dsc_timeout, since it is only used to specify the powershell command timeout
    # and timeout itself is not a parameter to the DSC resource
    next if property_name == :dsc_timeout

    # strip dsc_ from the beginning of the property name declaration
    name = property_name.to_s.gsub(/^dsc_/, '').to_sym
    params[:Property][name] = case property_hash[:mof_type]
                              when 'PSCredential'
                                # format can't unwrap Sensitive strings nested in arbitrary hashes/etc, so make
                                # the Credential hash interpolable as it will be replaced by a variable reference.
                                {
                                  'user' => property_hash[:value]['user'],
                                  'password' => escape_quotes(unwrap_string(property_hash[:value]['password']))
                                }
                              when 'DateTime'
                                # These have to be handled specifically because they rely on the *Puppet* DateTime,
                                # not a generic ruby data type (and so can't go in the shared util in ruby-pwsh)
                                "[DateTime]#{property_hash[:value].format('%FT%T%z')}"
                              else
                                property_hash[:value]
                              end
  end
  params_block = interpolate_variables("$InvokeParams = #{format(params)}")
  # Move the Apostrophe for DateTime declarations
  params_block = params_block.gsub("'[DateTime]", "[DateTime]'")
  # HACK: Handle intentionally empty arrays - need to strongly type them because
  # CIM instances do not do a consistent job of casting an empty array properly.
  empty_array_parameters = resource[:parameters].select { |_k, v| v[:value].is_a?(Array) && v[:value].empty? }
  empty_array_parameters.each do |name, properties|
    param_block_name = name.to_s.gsub(/^dsc_/, '')
    params_block = params_block.gsub("#{param_block_name} = @()", "#{param_block_name} = [#{properties[:mof_type]}]@()")
  end
  # HACK: make CIM instances work:
  resource[:parameters].select { |_key, hash| hash[:mof_is_embedded] && hash[:mof_type].include?('[]') }.each do |_property_name, property_hash|
    formatted_property_hash = interpolate_variables(format(property_hash[:value]))
    params_block = params_block.gsub(formatted_property_hash, "[CimInstance[]]#{formatted_property_hash}")
  end
  params_block
end

#invoke_set_method(context, name, should) ⇒ Hash

Invokes the Set method, passing the should hash as the properties to use with Invoke-DscResource The PowerShell script returns a JSON hash with key-value pairs indicating whether or not the resource is in the desired state, whether or not it requires a reboot, and any error messages captured.

Parameters:

  • the Puppet runtime context to operate in and send feedback to

  • the desired state represented definition to pass as properties to Invoke-DscResource

Returns:

  • 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.



727
728
729
730
731
732
733
734
735
# File 'lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb', line 727

def invoke_set_method(context, name, should)
  context.debug("Invoking Set Method for '#{name}' with #{should.inspect}")

  apply_props = should.select { |k, _v| k.to_s =~ /^dsc_/ }
  invoke_dsc_resource(context, should, apply_props, 'set')

  # TODO: Implement this functionality for notifying a DSC reboot?
  # notify_reboot_pending if data['rebootrequired'] == true
end

#invoke_test_method(context, name, should) ⇒ Boolean

Invokes the Test method, passing the should hash as the properties to use with Invoke-DscResource

The PowerShell script returns a JSON hash with key-value pairs indicating whether or not the resource
is in the desired state and any error messages captured.

Parameters:

  • the Puppet runtime context to operate in and send feedback to

  • the desired state represented definition to pass as properties to Invoke-DscResource

Returns:

  • returns true if the resource is in the desired state, otherwise false



744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
# File 'lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb', line 744

def invoke_test_method(context, name, should)
  context.debug("Relying on DSC Test method for validating if '#{name}' is in the desired state")
  context.debug("Invoking Test Method for '#{name}' with #{should.inspect}")

  test_props = should.select { |k, _v| k.to_s =~ /^dsc_/ }
  data = invoke_dsc_resource(context, name, test_props, 'test')
  # Something went wrong with Invoke-DscResource; fall back on property state comparisons
  return nil if data.nil?

  in_desired_state = data['indesiredstate']
  @cached_test_results << name.merge({ in_desired_state: in_desired_state })

  return in_desired_state if in_desired_state

  change_log = 'DSC reported that this resource is not in the desired state; treating all properties as out-of-sync'
  [in_desired_state, change_log]
end

#load_pathArray

Return the ruby $LOAD_PATH variable; this method exists to make testing vendored resource path discovery easier.

Returns:

  • The absolute file paths to available/known ruby code paths



832
833
834
# File 'lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb', line 832

def load_path
  $LOAD_PATH
end

#log_change_detail(context, name, is_value, should) ⇒ Object

Compares the is and should hashes and logs per-property change detail. The Resource API does not generate per-property change events when custom_insync is declared as a feature, so we emit them ourselves to populate report detail.

Parameters:

  • the Puppet runtime context to operate in and send feedback to

  • the name of the resource being changed

  • the current state of the resource

  • the desired state of the resource



240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
# File 'lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb', line 240

def log_change_detail(context, name, is_value, should)
  return if is_value.nil? || should.nil?

  # Build context info for the log line
  type_name = begin
    context.type.definition[:name]
  rescue StandardError
    nil
  end
  dsc_module = begin
    context.type.definition[:dscmeta_module_name]
  rescue StandardError
    nil
  end
  resource_title = name.is_a?(Hash) ? name[:name] : name
  # Try to extract the declaring Puppet class from tags if available
  tags = should[:tag] || should[:tags]
  declaring_class = if tags.is_a?(Array)
                      # Tags include type name, title, and all containing classes.
                      # Class tags contain '::' — pick the most specific one.
                      tags.reverse.find { |t| t.include?('::') }
                    end

  should.each do |property, desired_value|
    next unless property.to_s.start_with?('dsc_')
    next if property == :dsc_psdscrunascredential
    # Skip namevars — they're identifiers, not managed properties
    next if namevar_attributes(context).include?(property)

    current_value = is_value[property]
    # Skip if values are the same (case-insensitive for strings)
    next if same?(recursively_downcase(current_value), recursively_downcase(desired_value))

    mof_type = begin
      context.type.definition[:attributes][property][:mof_type]
    rescue StandardError
      nil
    end

    detail = "DSC_WORKAROUND: #{property} '#{current_value}' -> '#{desired_value}'"
    detail += " (#{mof_type})" if mof_type
    detail += " | resource: #{type_name}[#{resource_title}]" if type_name
    detail += " | dsc_module: #{dsc_module}" if dsc_module
    detail += " | class: #{declaring_class}" if declaring_class
    context.notice(detail)
  end
end

#logon_failed_already?(credential_hash) ⇒ Bool

Return true if the specified credential hash has already failed to execute a DSC resource due to a logon error, as when the account is not an administrator on the machine; otherwise returns false.

Parameters:

  • credential hash with a user and password keys where the password is a sensitive string

Returns:

  • true if the credential_hash has already failed logon, false otherwise



876
877
878
879
880
# File 'lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb', line 876

def logon_failed_already?(credential_hash)
  @logon_failures.any? do |failure_hash|
    failure_hash['user'] == credential_hash['user'] && failure_hash['password'].unwrap == credential_hash['password'].unwrap
  end
end

#mandatory_get_attributes(context) ⇒ Array

Parses the DSC resource type definition to retrieve the names of any attributes which are specified as mandatory for get operations

Parameters:

  • the Puppet runtime context to operate in and send feedback to

Returns:

  • returns an array of attribute names as symbols which are mandatory for get operations



959
960
961
# File 'lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb', line 959

def mandatory_get_attributes(context)
  context.type.attributes.select { |_attribute, properties| properties[:mandatory_for_get] }.keys
end

#mandatory_set_attributes(context) ⇒ Array

Parses the DSC resource type definition to retrieve the names of any attributes which are specified as mandatory for set operations

Parameters:

  • the Puppet runtime context to operate in and send feedback to

Returns:

  • returns an array of attribute names as symbols which are mandatory for set operations



967
968
969
# File 'lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb', line 967

def mandatory_set_attributes(context)
  context.type.attributes.select { |_attribute, properties| properties[:mandatory_for_set] }.keys
end

#munge_cim_instances!(enumerable) ⇒ Object



897
898
899
900
901
902
903
904
# File 'lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb', line 897

def munge_cim_instances!(enumerable)
  if enumerable.is_a?(Hash)
    # Delete the cim_instance_type key from a top-level CIM Instance **only**
    _ = enumerable.delete('cim_instance_type')
  else
    enumerable.each { |item| munge_cim_instances!(item) if item.is_a?(Enumerable) }
  end
end

#munge_psmodulepath(resource) ⇒ String

Parses a resource definition (as from invocable_resource) and ensures the System environment variable for PSModulePath is munged to include the vendored PowerShell modules. Due to a bug in PSDesiredStateConfiguration, class-based DSC Resources cannot be called via Invoke-DscResource by path, only by module name, and the module must be discoverable in the system-level PSModulePath. The postscript for invocation has logic to reset the system PSModulePath as stored in the script lines returned by this method.

Parameters:

  • a hash with the information needed to run Invoke-DscResource

Returns:

  • A multi-line string which sets the PSModulePath at the system level



1057
1058
1059
1060
1061
1062
1063
1064
1065
# File 'lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb', line 1057

def munge_psmodulepath(resource)
  vendor_path = resource[:vendored_modules_path]&.tr('/', '\\')
  "    $UnmungedPSModulePath = [System.Environment]::GetEnvironmentVariable('PSModulePath','machine')\n    $MungedPSModulePath = $env:PSModulePath + ';\#{vendor_path}'\n    Set-ItemProperty -Path 'HKLM:\\\\SYSTEM\\\\CurrentControlSet\\\\Control\\\\Session Manager\\\\Environment' -Name 'PSModulePath' -Value $MungedPSModulePath\n    $env:PSModulePath = [System.Environment]::GetEnvironmentVariable('PSModulePath','machine')\n  MUNGE_PSMODULEPATH\nend\n".strip

#namevar_attributes(context) ⇒ Array

Parses the DSC resource type definition to retrieve the names of any attributes which are specified as namevars

Parameters:

  • the Puppet runtime context to operate in and send feedback to

Returns:

  • returns an array of attribute names as symbols which are namevars



989
990
991
# File 'lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb', line 989

def namevar_attributes(context)
  context.type.attributes.select { |_attribute, properties| properties[:behaviour] == :namevar }.keys
end

#nested_cim_instances(enumerable) ⇒ Hash

Recursively search for and return CIM instances nested in an enumerable

Parameters:

  • a hash or array which may contain CIM Instances

Returns:

  • every discovered hash which does define a CIM Instance



1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
# File 'lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb', line 1152

def nested_cim_instances(enumerable)
  enumerable.collect do |key, value|
    if key.is_a?(Hash) && key.key?('cim_instance_type')
      key
      # TODO: Are there any cim instancees 3 levels deep, or only 2?
      # if so, we should *also* keep searching and processing...
    elsif key.is_a?(Enumerable)
      nested_cim_instances(key)
    elsif value.is_a?(Enumerable)
      nested_cim_instances(value)
    end
  end
end

#parameter_attributes(context) ⇒ Array

Parses the DSC resource type definition to retrieve the names of any attributes which are specified as parameters

Parameters:

  • the Puppet runtime context to operate in and send feedback to

Returns:

  • returns an array of attribute names as symbols which are parameters



997
998
999
# File 'lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb', line 997

def parameter_attributes(context)
  context.type.attributes.select { |_name, properties| properties[:behaviour] == :parameter }.keys
end

#perform_fresh_get(context, name, should) ⇒ Hash

Performs a fresh DSC Get call completely bypassing all caches to retrieve the actual current system state. Used by both insync? (for accurate property comparison and change_message tuples) and set() (for DSC_WORKAROUND log entries).

Parameters:

  • the Puppet runtime context to operate in and send feedback to

  • the name hash for the resource

  • the desired state hash (used to extract query properties)

Returns:

  • returns a hash with dsc_ prefixed keys and current system values, or nil on failure



296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
# File 'lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb', line 296

def perform_fresh_get(context, name, should)
  query_props = should.select { |k, v| mandatory_get_attributes(context).include?(k) || (k == :dsc_psdscrunascredential && !v.nil?) }
  data = invoke_dsc_resource(context, name, query_props, 'get')
  return nil if data.nil?

  # Minimal key processing to match the dsc_ prefix format used by Puppet types
  valid_attributes = context.type.attributes.keys.collect(&:to_s)
  result = {}
  data.each do |key, value|
    type_key = :"dsc_#{key.downcase}"
    next unless valid_attributes.include?(type_key.to_s)

    # Sanitize .NET serialization artifacts (e.g., "System.Object[]")
    if value.is_a?(String) && value =~ /^System\.\w+\[\]$/
      result[type_key] = nil
    else
      # Normalize CIM instances the same way invoke_get_method does,
      # so fresh values are comparable to canonicalized should values.
      if value.is_a?(Enumerable)
        downcase_hash_keys!(value)
        munge_cim_instances!(value)
      end
      result[type_key] = value
    end
  end
  result[:name] = name.is_a?(Hash) ? name[:name] : name
  result
rescue StandardError => e
  context.debug("perform_fresh_get failed: #{e.message}")
  nil
end

#prepare_cim_instances(resource) ⇒ String

Parses a resource definition (as from invocable_resource) for any properties which are CIM instances whether at the top level or nested inside of other CIM instances, and, where they are discovered, adds those objects to the instantiated_variables hash as well as returning a line of PowerShell code which will create the CIM object and store it in a variable. This then allows the CIM instances to be assigned by variable reference.

Parameters:

  • a hash with the information needed to run Invoke-DscResource

Returns:

  • An array of lines of PowerShell to instantiate CIM Instances and store them in variables



1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
# File 'lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb', line 1109

def prepare_cim_instances(resource)
  cim_instances_block = []
  resource[:parameters].each do |_property_name, property_hash|
    next unless property_hash[:mof_is_embedded]
    next if property_hash[:mof_type] == 'PSCredential' # Credentials are handled separately

    # strip dsc_ from the beginning of the property name declaration
    # name = property_name.to_s.gsub(/^dsc_/, '').to_sym
    # Process nested CIM instances first as those neeed to be passed to higher-order
    # instances and must therefore be declared before they must be referenced
    cim_instance_hashes = nested_cim_instances(property_hash[:value]).flatten.compact
    # Sometimes the instances are an empty array
    unless cim_instance_hashes.count.zero?
      cim_instance_hashes.each do |instance|
        variable_name = random_variable_name
        class_name = instance['cim_instance_type']
        properties = instance.reject { |k, _v| k == 'cim_instance_type' }
        cim_instances_block << format_ciminstance(variable_name, class_name, properties)
        instantiated_variables.merge!(variable_name => instance)
      end
    end
    # We have to handle arrays of CIM instances slightly differently
    if /\[\]$/.match?(property_hash[:mof_type])
      class_name = property_hash[:mof_type].gsub('[]', '')
      property_hash[:value].each do |hash|
        variable_name = random_variable_name
        cim_instances_block << format_ciminstance(variable_name, class_name, hash)
        instantiated_variables.merge!(variable_name => hash)
      end
    else
      variable_name = random_variable_name
      class_name = property_hash[:mof_type]
      cim_instances_block << format_ciminstance(variable_name, class_name, property_hash[:value])
      instantiated_variables.merge!(variable_name => property_hash[:value])
    end
  end
  cim_instances_block == [] ? '' : cim_instances_block.join("\n")
end

#prepare_credentials(resource) ⇒ String

Parses a resource definition (as from invocable_resource) for any properties which are PowerShell Credentials. As these values need to be serialized into PSCredential objects, return an array of PowerShell lines, each of which instantiates a variable which holds the value as a PSCredential. These credential variables can then be simply assigned in the parameter hash where needed.

Parameters:

  • a hash with the information needed to run Invoke-DscResource

Returns:

  • An array of lines of PowerShell to instantiate PSCredentialObjects and store them in variables



1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
# File 'lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb', line 1074

def prepare_credentials(resource)
  credentials_block = []
  resource[:parameters].each do |_property_name, property_hash|
    next unless property_hash[:mof_type] == 'PSCredential'
    next if property_hash[:value].nil?

    variable_name = random_variable_name
    credential_hash = {
      'user' => property_hash[:value]['user'],
      'password' => escape_quotes(unwrap_string(property_hash[:value]['password']))
    }
    credentials_block << format_pscredential(variable_name, credential_hash)
    instantiated_variables.merge!(variable_name => credential_hash)
  end
  credentials_block.join("\n")
  credentials_block == [] ? '' : credentials_block
end

#preserve_namevar_values!(context, data, name_hash) ⇒ Object

Preserve namevar values from the input hash when DSC returns nil/empty.



711
712
713
714
715
716
717
718
# File 'lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb', line 711

def preserve_namevar_values!(context, data, name_hash)
  data[:name] = name_hash[:name]
  namevar_attributes(context).each do |namevar|
    next unless name_hash.key?(namevar)

    data[namevar] = name_hash[namevar] if data[namevar].nil? || (data[namevar].respond_to?(:empty?) && data[namevar].empty?)
  end
end

#ps_managerObject

Instantiate a PowerShell manager via the ruby-pwsh library and use it to invoke PowerShell. Definiing it here allows re-use of a single instance instead of continually instantiating and tearing a new instance down for every call.



1408
1409
1410
1411
1412
1413
1414
1415
1416
# File 'lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb', line 1408

def ps_manager
  debug_output = Puppet::Util::Log.level == :debug
  # TODO: Allow you to specify an alternate path, either to pwsh generally or a specific pwsh path.
  if Pwsh::Util.on_windows?
    Pwsh::Manager.instance(Pwsh::Manager.powershell_path, Pwsh::Manager.powershell_args, debug: debug_output)
  else
    Pwsh::Manager.instance(Pwsh::Manager.pwsh_path, Pwsh::Manager.pwsh_args, debug: debug_output)
  end
end

#ps_script_content(resource) ⇒ String

Given a resource definition (as from invocable_resource), return a PowerShell script which has all of the appropriate function and variable definitions, which will call Invoke-DscResource, and will correct munge the results for returning to Puppet as a JSON object.

Parameters:

  • a hash with the information needed to run Invoke-DscResource

Returns:

  • A string representing the PowerShell script which will invoke the DSC Resource.



1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
# File 'lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb', line 1252

def ps_script_content(resource)
  template_path = File.expand_path(__dir__)
  # Defines the helper functions
  functions     = File.new("#{template_path}/invoke_dsc_resource_functions.ps1").read
  # Defines the response hash and the runtime settings
  preamble      = File.new("#{template_path}/invoke_dsc_resource_preamble.ps1").read
  # The postscript defines the invocation error and result handling; expects `$InvokeParams` to be defined
  postscript    = File.new("#{template_path}/invoke_dsc_resource_postscript.ps1").read
  # The blocks define the variables to define for the postscript.
  module_path_block = munge_psmodulepath(resource)
  credential_block = prepare_credentials(resource)
  cim_instances_block = prepare_cim_instances(resource)
  parameters_block = invoke_params(resource)
  # 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
  clear_instantiated_variables!

  [functions, preamble, module_path_block, credential_block, cim_instances_block, parameters_block, postscript].join("\n")
end

#puppetize_name(name) ⇒ String

Return a String containing a puppetized name. A puppetized name is a string that only includes lowercase letters, digits, underscores and cannot start with a digit.

Returns:

  • with a puppetized module name



840
841
842
843
844
845
846
847
# File 'lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb', line 840

def puppetize_name(name)
  # Puppet module names must be lower case
  name = name.downcase
  # Puppet module names must only include lowercase letters, digits and underscores
  name = name.gsub(/[^a-z0-9_]/, '_')
  # Puppet module names must not start with a number so if it does, append the letter 'a' to the name. Eg: '7zip' becomes 'a7zip'
  name = name.match?(/^\d/) ? "a#{name}" : name # rubocop:disable Lint/UselessAssignment
end

#random_variable_nameString

Return a UUID with the dashes turned into underscores to enable the specifying of guaranteed-unique variables in the PowerShell script.

Returns:

  • a uuid with underscores instead of dashes.



853
854
855
856
# File 'lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb', line 853

def random_variable_name
  # PowerShell variables can't include dashes
  SecureRandom.uuid.tr('-', '_')
end

#recursively_downcase(object) ⇒ Object

Recursively transforms any object, downcasing it to enable case insensitive comparisons

Parameters:

  • a string, array, hash, or other object to attempt to recursively downcase

Returns:

  • returns the input object recursively downcased



910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
# File 'lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb', line 910

def recursively_downcase(object)
  case object
  when String
    object.downcase
  when Array
    object.map { |item| recursively_downcase(item) }
  when Hash
    transformed = {}
    object.transform_keys(&:downcase).each do |key, value|
      transformed[key] = recursively_downcase(value)
    end
    transformed
  else
    object
  end
end

#recursively_sort(object) ⇒ Object

Recursively sorts any object to enable order-insensitive comparisons

Parameters:

  • an array, hash, or other object to attempt to recursively downcase

Returns:

  • returns the input object recursively downcased



931
932
933
934
935
936
937
938
939
940
941
942
943
944
# File 'lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb', line 931

def recursively_sort(object)
  case object
  when Array
    object.map { |item| recursively_sort(item) }.sort_by(&:to_s)
  when Hash
    transformed = {}
    object.sort.to_h.each do |key, value|
      transformed[key] = recursively_sort(value)
    end
    transformed
  else
    object
  end
end

#redact_secrets(text) ⇒ String

While Puppet is aware of Sensitive data types, the PowerShell script is not and so for debugging purposes must be redacted before being sent to debug output but must not be redacted when sent to the PowerShell code manager.

Parameters:

  • the text to redact

Returns:

  • the redacted text



1391
1392
1393
# File 'lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb', line 1391

def redact_secrets(text)
  handle_secrets(text, "'#<Sensitive [value redacted]>'", "Unredacted sensitive data would've been leaked")
end

#remove_secret_identifiers(text) ⇒ String

While Puppet is aware of Sensitive data types, the PowerShell script is not and so the helper-id for sensitive data must be removed before sending to the PowerShell code manager.

Parameters:

  • the text to strip of secret data identifiers

Returns:

  • the modified text to pass to the PowerShell code manager



1401
1402
1403
# File 'lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb', line 1401

def remove_secret_identifiers(text)
  handle_secrets(text, "'\\k<secret>'", 'Unable to properly format text for PowerShell with sensitive data')
end

#retry_invoke_dsc_resource(context, max_retry_count, retry_wait_interval_secs, error_matcher) ⇒ Object

Retries Invoke-DscResource when returned error matches error regex supplied as param.

Parameters:

  • the Puppet runtime context to operate in and send feedback to

  • max number of times to retry Invoke-DscResource

  • Time delay between retries

  • the regex pattern to match with error



462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
# File 'lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb', line 462

def retry_invoke_dsc_resource(context, max_retry_count, retry_wait_interval_secs, error_matcher)
  try = 0
  while try < max_retry_count
    try += 1
    # notify and wait for retry interval
    context.notice("Sleeping for #{retry_wait_interval_secs} seconds.")
    sleep retry_wait_interval_secs
    # notify and retry
    context.notice("Retrying: attempt #{try} of #{max_retry_count}.")
    data = JSON.parse(yield)
    # if no error, assume successful invocation and break
    break if data['errormessage'].nil?

    # notify of failed retry
    context.notice("Attempt #{try} of #{max_retry_count} failed.")
    # return if error does not match expceted error, or all retries exhausted
    return context.err(data['errormessage']) unless data['errormessage'].match?(error_matcher) && try < max_retry_count

    # else, retry
    next
  end
  data
end

#same?(value1, value2) ⇒ bool

Check equality, sort if necessary

Parameters:

  • a string, array, hash, or other object to sort and compare to value2

  • a string, array, hash, or other object to sort and compare to value1

Returns:

  • returns equality



951
952
953
# File 'lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb', line 951

def same?(value1, value2)
  recursively_sort(value2) == recursively_sort(value1)
end

#sanitize_dotnet_artifacts!(context, data) ⇒ Object

Sanitize .NET serialization artifacts (e.g., “System.Object[]”) to nil.



701
702
703
704
705
706
707
708
# File 'lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb', line 701

def sanitize_dotnet_artifacts!(context, data)
  data.each do |key, value|
    next unless value.is_a?(String) && value =~ /^System\.\w+\[\]$/

    context.debug("Sanitizing .NET serialization artifact for #{key}: #{value} -> nil")
    data[key] = nil
  end
end

#set(context, changes) ⇒ Object

Determines whether a resource is ensurable and which message to write (create, update, or delete), then passes the appropriate values along to the various sub-methods which themselves call the Set method of Invoke-DscResource. Implementation borrowed directly from the Resource API Simple Provider

Parameters:

  • the Puppet runtime context to operate in and send feedback to

  • the hash of whose key is the name_hash and value is the is and should hashes



183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
# File 'lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb', line 183

def set(context, changes)
  changes.each do |name, change|
    is = change[:is]
    should = change[:should]

    # If should is an array instead of a hash and only has one entry, use that.
    should = should.first if should.is_a?(Array) && should.length == 1

    # DSC_WORKAROUND logging disabled — the insync? tuple returns now provide
    # accurate per-property change detail in PE Events, making this redundant.
    # Retained commented out for future debugging if needed.
    # fresh_is = get_fresh_state_for_logging(context, name, should)
    # log_change_detail(context, name, fresh_is || is, should)

    # for compatibility sake, we use dsc_ensure instead of ensure, so context.type.ensurable? does not work
    if context.type.attributes.key?(:dsc_ensure)
      # HACK: If the DSC Resource is ensurable but doesn't report a default value
      # for ensure, we assume it to be `Present` - this is the most common pattern.
      should_ensure = should[:dsc_ensure].nil? ? 'Present' : should[:dsc_ensure].to_s
      # HACK: Sometimes dsc_ensure is removed???? If it's gone, pretend it's absent??
      is_ensure = is[:dsc_ensure].nil? ? 'Absent' : is[:dsc_ensure].to_s

      if is_ensure == 'Absent' && should_ensure == 'Present'
        context.creating(name) do
          create(context, name, should)
        end
      elsif is_ensure == 'Present' && should_ensure == 'Present'
        context.updating(name) do
          update(context, name, should)
        end
      elsif is_ensure == 'Present' && should_ensure == 'Absent'
        context.deleting(name) do
          delete(context, name)
        end
      else
        # In this case we are not sure if the resource is being created/updated/removed
        # as with ensure "latest" or a specific version number, so default to update.
        context.updating(name) do
          update(context, name, should)
        end
      end
    else
      context.updating(name) do
        update(context, name, should)
      end
    end
  end
end

#specify_dsc_timeout(name_hash) ⇒ Object

Sets the @timeout instance variable. The @timeout variable is set to the value of name_hash in milliseconds If name_hash is nil, @timeout is not changed. If @timeout is already set to a value other than nil, it is changed only if it’s different from name_hash..

Parameters:

  • the hash of namevars to be passed as properties to Invoke-DscResource



451
452
453
454
455
# File 'lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb', line 451

def specify_dsc_timeout(name_hash)
  return unless name_hash[:dsc_timeout] && (@timeout.nil? || @timeout != name_hash[:dsc_timeout])

  @timeout = name_hash[:dsc_timeout] * 1000
end

#stringify_nil_attributes(context, data) ⇒ Hash

Parses the DSC resource type definition to retrieve the names of any attributes which are specified as required strings This is used to ensure that any nil values are converted to empty strings to match puppets expeceted value

Parameters:

  • the Puppet runtime context to operate in and send feedback to

  • the hash of properties returned from the DSC resource

Returns:

  • returns a data hash with any nil values converted to empty strings



976
977
978
979
980
981
982
983
# File 'lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb', line 976

def stringify_nil_attributes(context, data)
  nil_attributes = data.select { |_name, value| value.nil? }.keys
  nil_attributes.each do |nil_attr|
    attribute_type = context.type.attributes[nil_attr][:type]
    data[nil_attr] = '' if (attribute_type.start_with?('Optional[Enum[', 'Enum[') && enum_values(context, nil_attr).include?('')) || attribute_type == 'String'
  end
  data
end

#unwrap(value) ⇒ Object

Unwrap sensitive strings for formatting, even inside an enumerable, appending the the secret postfix to the end of the string in preparation for gsub cleanup.

Parameters:

  • The object to unwrap sensitive data inside of

Returns:

  • The object with any sensitive strings unwrapped and annotated



1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
# File 'lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb', line 1290

def unwrap(value)
  case value
  when Puppet::Pops::Types::PSensitiveType::Sensitive
    "#{value.unwrap}#{SECRET_POSTFIX}"
  when Hash
    unwrapped = {}
    value.each do |k, v|
      unwrapped[k] = unwrap(v)
    end
    unwrapped
  when Array
    unwrapped = []
    value.each do |v|
      unwrapped << unwrap(v)
    end
    unwrapped
  else
    value
  end
end

#unwrap_string(value) ⇒ Object

Unwrap sensitive strings and handle string

Parameters:

  • The object to unwrap sensitive data inside of

Returns:

  • The object with any sensitive strings unwrapped



1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
# File 'lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb', line 1315

def unwrap_string(value)
  case value
  when Puppet::Pops::Types::PSensitiveType::Sensitive
    value.unwrap
  when Hash
    unwrapped = {}
    value.each do |k, v|
      unwrapped[k] = unwrap_string(v)
    end
    unwrapped
  when Array
    unwrapped = []
    value.each do |v|
      unwrapped << unwrap_string(v)
    end
    unwrapped
  else
    value
  end
end

#update(context, name, should) ⇒ Hash

Attempts to set an instance of the DSC resource, invoking the Set method and thinly wrapping the invoke_set_method method; whether this method, create, or delete is called is entirely up to the Resource API based on the results

Parameters:

  • the Puppet runtime context to operate in and send feedback to

  • the name of the resource being created

Returns:

  • 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.



365
366
367
368
# File 'lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb', line 365

def update(context, name, should)
  context.debug("Updating '#{name}' with #{should.inspect}")
  invoke_set_method(context, name, should)
end

#values_equal?(is_value, should_value) ⇒ Boolean

Compare two values with type coercion. Handles the mismatches common in DSC: integers vs strings, case differences, array ordering, and nested CIM instances. Uses the same recursively_sort/recursively_downcase helpers the provider already relies on for canonicalize and log_change_detail comparisons.

Parameters:

  • the current value from a fresh DSC Get

  • the desired value from the Puppet manifest

Returns:

  • true if the values are equivalent



494
495
496
497
498
499
500
501
502
503
504
505
# File 'lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb', line 494

def values_equal?(is_value, should_value)
  return true if is_value == should_value

  # Handle nil/empty equivalence
  is_empty = is_value.nil? || (is_value.respond_to?(:empty?) && is_value.empty?)
  should_empty = should_value.nil? || (should_value.respond_to?(:empty?) && should_value.empty?)
  return true if is_empty && should_empty

  # Normalize and compare: sort for order-insensitive array/hash comparison,
  # downcase for case-insensitive string comparison, handles nested structures.
  same?(recursively_downcase(is_value), recursively_downcase(should_value))
end

#vendored_modules_path(module_name) ⇒ Object



797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
# File 'lib/puppet/provider/dsc_base_provider/dsc_base_provider.rb', line 797

def vendored_modules_path(module_name)
  # Because Puppet adds all of the modules to the LOAD_PATH we can be sure that the appropriate module lives here during an apply;
  # PROBLEM: This currently uses the downcased name, we need to capture the module name in the metadata I think.
  # During a Puppet agent run, the code lives in the cache so we can use the file expansion to discover the correct folder.
  # This handles setting the vendored_modules_path to include the puppet module name; we now add the puppet module name into the
  # path to allow multiple modules to with shared dsc_resources to be installed side by side
  # The old vendored_modules_path: puppet_x/dsc_resources
  # The new vendored_modules_path: puppet_x/<module_name>/dsc_resources
  root_module_path = load_path.grep(%r{#{puppetize_name(module_name)}/lib}).first
  vendored_path = if root_module_path.nil?
                    File.expand_path(Pathname.new(__FILE__).dirname + '../../../' + "puppet_x/#{puppetize_name(module_name)}/dsc_resources") # rubocop:disable Style/StringConcatenation
                  else
                    File.expand_path("#{root_module_path}/puppet_x/#{puppetize_name(module_name)}/dsc_resources")
                  end

  # Check for the old vendored_modules_path second - if there is a mix of modules with the old and new pathing,
  # checking for this first will always work and so the more specific search will never run.
  unless File.exist? vendored_path
    vendored_path = if root_module_path.nil?
                      File.expand_path(Pathname.new(__FILE__).dirname + '../../../' + 'puppet_x/dsc_resources') # rubocop:disable Style/StringConcatenation
                    else
                      File.expand_path("#{root_module_path}/puppet_x/dsc_resources")
                    end
  end

  # A warning is thrown if the something went wrong and the file was not created
  raise "Unable to find expected vendored DSC Resource #{vendored_path}" unless File.exist? vendored_path

  vendored_path
end