1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
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
125
126
127
128
129
130
131
132
133
134
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
176
177
178
179
180
181
182
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
231
232
233
234
235
236
237
238
239
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
287
288
289
290
291
292
293
294
295
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
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
|
#
# Copyright:: Copyright 2008-2016, Chef, Inc.
# License:: Apache License, Version 2.0
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
require "addressable/template" unless defined?(Addressable::Template)
require "rest-client" unless defined?(RestClient)
require "jmespath" unless defined?(JMESPath)
require "chef/dsl/rest_resource" unless defined?(Chef::DSL::RestResource)
extend Chef::DSL::RestResource
action_class do
def load_current_resource
@current_resource = new_resource.class.new(new_resource.name)
required_properties.each do |name|
requested = new_resource.send(name)
current_resource.send(name, requested)
end
return @current_resource if rest_get_all.data.empty?
resource_data = rest_get.data rescue nil
return @current_resource if resource_data.nil? || resource_data.empty?
@resource_exists = true
# Map JSON contents to defined properties
current_resource.class.rest_property_map.each do |property, match_instruction|
property_value = json_to_property(match_instruction, property, resource_data)
current_resource.send(property, property_value) unless property_value.nil?
end
current_resource
end
end
action :configure do
if resource_exists?
converge_if_changed do
data = {}
new_resource.class.rest_property_map.each do |property, match_instruction|
# Skip "creation-only" properties on modifications
next if new_resource.class.rest_post_only_properties.include?(property)
deep_merge! data, property_to_json(property, match_instruction)
end
deep_compact!(data)
rest_patch(data)
end
else
converge_by "creating resource" do
data = {}
new_resource.class.rest_property_map.each do |property, match_instruction|
deep_merge! data, property_to_json(property, match_instruction)
end
deep_compact!(data)
rest_post(data)
end
end
end
action :delete do
if resource_exists?
converge_by "deleting resource" do
rest_delete
end
else
logger.debug format("REST resource %<name>s of type %<type>s does not exist. Skipping.",
type: new_resource.name, name: id_property)
end
end
action_class do
# Override this for postprocessing device-specifics (paging, data conversion)
def rest_postprocess(response)
response
end
# Override this for error handling of device-specifics (readable error messages)
def rest_errorhandler(error_obj)
error_obj
end
private
def resource_exists?
@resource_exists
end
def required_properties
current_resource.class.properties.select { |_, v| v.required? }.except(:name).keys
end
# Return changed value or nil for delta current->new
def changed_value(property)
new_value = new_resource.send(property)
return new_value if current_resource.nil?
current_value = current_resource.send(property)
return current_value if required_properties.include? property
new_value == current_value ? nil : new_value
end
def id_property
current_resource.class.identity_attr
end
# Map properties to their current values
def property_map
map = {}
current_resource.class.state_properties.each do |property|
name = property.options[:name]
map[name] = current_resource.send(name)
end
map[id_property] = current_resource.send(id_property)
map
end
# Map part of a JSON (Hash) to resource property via JMESPath or user-supplied function
def json_to_property(match_instruction, property, resource_data)
case match_instruction
when String
JMESPath.search(match_instruction, resource_data)
when Symbol
function = "#{property}_from_json".to_sym
raise "#{new_resource.name} missing #{function} method" unless self.class.protected_method_defined?(function)
send(function, resource_data) || {}
else
raise TypeError, "Did not expect match type #{match_instruction.class}"
end
end
# Map resource contents into a JSON (Hash) via JMESPath-like syntax or user-supplied function
def property_to_json(property, match_instruction)
case match_instruction
when String
bury(match_instruction, changed_value(property))
when Symbol
function = "#{property}_to_json".to_sym
raise "#{new_resource.name} missing #{function} method" unless self.class.protected_method_defined?(function)
value = new_resource.send(property)
changed_value(property).nil? ? {} : send(function, value)
else
raise TypeError, "Did not expect match type #{match_instruction.class}"
end
end
def rest_url_collection
current_resource.class.rest_api_collection
end
# Resource document URL after RFC 6570 template evaluation via properties substitution
def rest_url_document
template = ::Addressable::Template.new(current_resource.class.rest_api_document)
template.expand(property_map).to_s
end
# Convenience method for conditional requires
def conditionally_require_on_setting(property, dependent_properties)
dependent_properties = Array(dependent_properties)
requirements.assert(:configure) do |a|
a.assertion do
# Needs to be set and truthy to require dependent properties
if new_resource.send(property)
dependent_properties.all? { |dep_prop| new_resource.property_is_set?(dep_prop) }
else
true
end
end
message = format("Setting property :%<property>s requires properties :%<properties>s to be set as well on resource %<resource_name>s",
property: property,
properties: dependent_properties.join(", :"),
resource_name: current_resource.to_s)
a.failure_message message
end
end
# Generic REST helpers
def rest_get_all
response = api_connection.get(rest_url_collection)
rest_postprocess(response)
rescue RestClient::Exception => e
rest_errorhandler(e)
end
def rest_get
response = api_connection.get(rest_url_document)
response = rest_postprocess(response)
first_only = current_resource.class.rest_api_document_first_element_only
response.data = response.data.first if first_only && response.data.is_a?(Array)
response
rescue RestClient::Exception => e
rest_errorhandler(e)
end
def rest_post(data)
data.merge! rest_identity_values
response = api_connection.post(rest_url_collection, data: data)
rest_postprocess(response)
rescue RestClient::Exception => e
rest_errorhandler(e)
end
def rest_put(data)
data.merge! rest_identity_values
response = api_connection.put(rest_url_collection, data: data)
rest_postprocess(response)
rescue RestClient::Exception => e
rest_errorhandler(e)
end
def rest_patch(data)
response = api_connection.patch(rest_url_document, data: data)
rest_postprocess(response)
rescue RestClient::Exception => e
rest_errorhandler(e)
end
def rest_delete
response = api_connection.delete(rest_url_document)
rest_postprocess(response)
rescue RestClient::Exception => e
rest_errorhandler(e)
end
# REST parameter mapping
# Return number of parameters needed to identify a resource (pre- and post-creation)
def rest_arity
rest_identity_map.keys.count
end
# Return mapping of template placeholders to property value of identity parameters
def rest_identity_values
data = {}
rest_identity_map.each do |rfc_template, property|
property_value = new_resource.send(property)
data.merge! bury(rfc_template, property_value)
end
data
end
def rest_identity_map
rest_identity_explicit || rest_identity_implicit
end
# Accept direct mapping like { "svm.name" => :name } for specifying the x-ary identity of a resource
def rest_identity_explicit
current_resource.class.rest_identity_map
end
# Parse document URL for RFC 6570 templates and map them to resource properties.
#
# Examples:
# Query based: "/api/protocols/san/igroups?name={name}&svm.name={svm}": { "name" => :name, "svm.name" => :svm }
# Path based: "/api/v1/{address}": { "address" => :address }
#
def rest_identity_implicit
template_url = current_resource.class.rest_api_document
rfc_template = ::Addressable::Template.new(template_url)
rfc_template_vars = rfc_template.variables
# Shortcut for 0-ary resources
return {} if rfc_template_vars.empty?
if query_based_selection?
uri_query = URI.parse(template_url).query
if CGI.parse(uri_query).values.any?(&:empty?)
raise "Need explicit identity mapping, as URL does not contain query parameters for all templates"
end
path_variables = CGI.parse(uri_query).keys
elsif path_based_selection?
path_variables = rfc_template_vars
else
# There is also
raise "Unknown type of resource selection. Document URL does not seem to be path- or query-based?"
end
identity_map = {}
path_variables.each_with_index do |v, i|
next if rfc_template_vars[i].nil? # Not mapped to property, assume metaparameter
identity_map[v] = rfc_template_vars[i].to_sym
end
identity_map
end
def query_based_selection?
template_url = current_resource.class.rest_api_document
# Will throw exception on presence of RFC 6570 templates
URI.parse(template_url)
true
rescue URI::InvalidURIError => _e
false
end
def path_based_selection?
!query_based_selection?
end
def api_connection
Chef.run_context.transport.connection
end
# Remove all empty keys (recusively) from Hash.
# @see https://stackoverflow.com/questions/56457020/#answer-56458673
def deep_compact!(hsh)
raise TypeError unless hsh.is_a? Hash
hsh.each do |_, v|
deep_compact!(v) if v.is_a? Hash
end.reject! { |_, v| v.nil? || (v.respond_to?(:empty?) && v.empty?) }
end
# Deep merge two hashes
# @see https://stackoverflow.com/questions/41109599#answer-41109737
def deep_merge!(hsh1, hsh2)
raise TypeError unless hsh1.is_a?(Hash) && hsh2.is_a?(Hash)
hsh1.merge!(hsh2) { |_, v1, v2| deep_merge!(v1, v2) }
end
# Create nested hashes from JMESPath syntax.
def bury(path, value)
raise TypeError unless path.is_a?(String)
arr = path.split(".")
ret = {}
if arr.count == 1
ret[arr.first] = value
ret
else
partial_path = arr[0..-2].join(".")
bury(partial_path, bury(arr.last, value))
end
end
end
|