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