summaryrefslogtreecommitdiff
path: root/lib/chef/resource/_rest_resource.rb
blob: 8e72073b8879ce1e694637a3570447b930bf9d15 (plain)
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