ruby-pwsh 0.1.0 → 0.5.0
Sign up to get free protection for your applications and to get access to all the features.
- 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
|